batch.inc 16.7 KB
Newer Older
1 2 3
<?php

/**
4 5 6
 * @file
 * Batch processing API for processes to run in multiple HTTP requests.
 *
7
 * Note that batches are usually invoked by form submissions, which is
8 9 10 11 12 13 14
 * why the core interaction functions of the batch processing API live in
 * form.inc.
 *
 * @see form.inc
 * @see batch_set()
 * @see batch_process()
 * @see batch_get()
15 16
 */

17
use Drupal\Component\Utility\SafeMarkup;
18
use Drupal\Component\Utility\Timer;
19
use Drupal\Component\Utility\UrlHelper;
20
use Drupal\Core\Batch\Percentage;
21
use Drupal\Core\Form\FormState;
22
use Drupal\Core\Page\DefaultHtmlPageRenderer;
23
use Drupal\Core\Url;
24
use Symfony\Component\HttpFoundation\JsonResponse;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\HttpFoundation\RedirectResponse;
27

28
/**
29
 * Renders the batch processing page based on the current state of the batch.
30
 *
31 32 33
 * @param \Symfony\Component\HttpFoundation\Request $request
 *   The current request object.
 *
34
 * @see _batch_shutdown()
35
 */
36
function _batch_page(Request $request) {
37
  $batch = &batch_get();
38

39
  if (!($request_id = $request->get('id'))) {
40 41 42
    return FALSE;
  }

43
  // Retrieve the current state of the batch.
44
  if (!$batch) {
45
    $batch = \Drupal::service('batch.storage')->load($request_id);
46 47
    if (!$batch) {
      drupal_set_message(t('No active batch.'), 'error');
48
      return new RedirectResponse(url('<front>', array('absolute' => TRUE)));
49
    }
50
  }
51 52 53 54 55 56
  // Restore safe strings from previous batches.
  // @todo Ensure we are not storing an excessively large string list in:
  //   https://www.drupal.org/node/2295823
  if (!empty($batch['safe_strings'])) {
    SafeMarkup::setMultiple($batch['safe_strings']);
  }
57
  // Register database update for the end of processing.
58
  drupal_register_shutdown_function('_batch_shutdown');
59

60 61
  $build = array();

62
  // Add batch-specific CSS.
63
  foreach ($batch['sets'] as $batch_set) {
64 65
    if (isset($batch_set['css'])) {
      foreach ($batch_set['css'] as $css) {
66
        $build['#attached']['css'][$css] = array();
67
      }
68 69 70
    }
  }

71
  $op = $request->get('op', '');
72 73
  switch ($op) {
    case 'start':
74
    case 'do_nojs':
75 76
      // Display the full progress page on startup and on each additional
      // non-JavaScript iteration.
77 78 79
      $current_set = _batch_current_set();
      $build['#title'] = $current_set['title'];
      $build['content'] = _batch_progress_page();
80 81 82
      break;

    case 'do':
83
      // JavaScript-based progress page callback.
84
      return _batch_do();
85 86

    case 'finished':
87 88
      // _batch_finished() returns a RedirectResponse.
      return _batch_finished();
89 90
  }

91
  return $build;
92 93 94
}

/**
95
 * Does one execution pass with JavaScript and returns progress to the browser.
96 97 98
 *
 * @see _batch_progress_page_js()
 * @see _batch_process()
99 100
 */
function _batch_do() {
101
  // Perform actual processing.
102
  list($percentage, $message, $label) = _batch_process();
103

104
  return new JsonResponse(array('status' => TRUE, 'percentage' => $percentage, 'message' => $message, 'label' => $label));
105 106 107
}

/**
108
 * Outputs a batch processing page.
109 110
 *
 * @see _batch_process()
111
 */
112
function _batch_progress_page() {
113
  $batch = &batch_get();
114

115
  $current_set = _batch_current_set();
116 117 118 119

  $new_op = 'do_nojs';

  if (!isset($batch['running'])) {
120
    // This is the first page so we return some output immediately.
121 122
    $percentage       = 0;
    $message          = $current_set['init_message'];
123
    $label            = '';
124 125 126
    $batch['running'] = TRUE;
  }
  else {
127
    // This is one of the later requests; do some processing first.
128

129 130 131
    // Error handling: if PHP dies due to a fatal error (e.g. a nonexistent
    // function), it will output whatever is in the output buffer, followed by
    // the error message.
132
    ob_start();
133
    $fallback = $current_set['error_message'] . '<br />' . $batch['error_message'];
134 135

    // We strip the end of the page using a marker in the template, so any
136 137 138
    // additional HTML output by PHP shows up inside the page rather than below
    // it. While this causes invalid HTML, the same would be true if we didn't,
    // as content is not allowed to appear after </html> anyway.
139 140 141
    $fallback = DefaultHtmlPageRenderer::renderPage($fallback, $current_set['title'], 'maintenance', array(
      '#show_messages' => FALSE,
    ));
142 143 144
    list($fallback) = explode('<!--partial-->', $fallback);
    print $fallback;

145
    // Perform actual processing.
146
    list($percentage, $message, $label) = _batch_process($batch);
147 148 149 150
    if ($percentage == 100) {
      $new_op = 'finished';
    }

151
    // PHP did not die; remove the fallback output.
152 153 154
    ob_end_clean();
  }

155 156 157 158 159 160
  // Merge required query parameters for batch processing into those provided by
  // batch_set() or hook_batch_alter().
  $batch['url_options']['query']['id'] = $batch['id'];
  $batch['url_options']['query']['op'] = $new_op;

  $url = url($batch['url'], $batch['url_options']);
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195

  $build = array(
    '#theme' => 'progress_bar',
    '#percent' => $percentage,
    '#message' => $message,
    '#label' => $label,
    '#attached' => array(
      'drupal_add_html_head' => array(
        array(
          array(
            // Redirect through a 'Refresh' meta tag if JavaScript is disabled.
            '#tag' => 'meta',
            '#noscript' => TRUE,
            '#attributes' => array(
              'http-equiv' => 'Refresh',
              'content' => '0; URL=' . $url,
            ),
          ),
          'batch_progress_meta_refresh',
        ),
      ),
      // Adds JavaScript code and settings for clients where JavaScript is enabled.
      'js' => array(
        array(
          'type' => 'setting',
          'data' => array(
            'batch' => array(
              'errorMessage' => $current_set['error_message'] . '<br />' . $batch['error_message'],
              'initMessage' => $current_set['init_message'],
              'uri' => $url,
            ),
          ),
        ),
      ),
      'library' => array(
196
        'core/drupal.batch',
197
      ),
198 199
    ),
  );
200
  return $build;
201 202 203
}

/**
204
 * Processes sets in a batch.
205 206 207 208 209 210 211 212
 *
 * If the batch was marked for progressive execution (default), this executes as
 * many operations in batch sets until an execution time of 1 second has been
 * exceeded. It will continue with the next operation of the same batch set in
 * the next request.
 *
 * @return
 *   An array containing a completion value (in percent) and a status message.
213 214
 */
function _batch_process() {
215 216 217
  $batch       = &batch_get();
  $current_set = &_batch_current_set();
  // Indicate that this batch set needs to be initialized.
218
  $set_changed = TRUE;
219

220
  // If this batch was marked for progressive execution (e.g. forms submitted by
221
  // drupal_form_submit()), initialize a timer to determine whether we need to
222 223
  // proceed with the same batch phase when a processing time of 1 second has
  // been exceeded.
224
  if ($batch['progressive']) {
225
    Timer::start('batch_processing');
226 227
  }

228 229 230 231 232 233
  if (empty($current_set['start'])) {
    $current_set['start'] = microtime(TRUE);
  }

  $queue = _batch_queue($current_set);

234
  while (!$current_set['success']) {
235 236 237 238
    // If this is the first time we iterate this batch set in the current
    // request, we check if it requires an additional file for functions
    // definitions.
    if ($set_changed && isset($current_set['file']) && is_file($current_set['file'])) {
239
      include_once DRUPAL_ROOT . '/' . $current_set['file'];
240 241
    }

242
    $task_message = $label = '';
243
    // Assume a single pass operation and set the completion level to 1 by
244
    // default.
245
    $finished = 1;
246 247

    if ($item = $queue->claimItem()) {
248
      list($callback, $args) = $item->data;
249 250

      // Build the 'context' array and execute the function call.
251 252 253 254 255 256
      $batch_context = array(
        'sandbox'  => &$current_set['sandbox'],
        'results'  => &$current_set['results'],
        'finished' => &$finished,
        'message'  => &$task_message,
      );
257
      call_user_func_array($callback, array_merge($args, array(&$batch_context)));
258

259
      if ($finished >= 1) {
260 261 262 263 264 265 266
        // Make sure this step is not counted twice when computing $current.
        $finished = 0;
        // Remove the processed operation and clear the sandbox.
        $queue->deleteItem($item);
        $current_set['count']--;
        $current_set['sandbox'] = array();
      }
267
    }
268

269
    // When all operations in the current batch set are completed, browse
270 271 272 273 274
    // through the remaining sets, marking them 'successfully processed'
    // along the way, until we find a set that contains operations.
    // _batch_next_set() executes form submit handlers stored in 'control'
    // sets (see form_execute_handlers()), which can in turn add new sets to
    // the batch.
275 276
    $set_changed = FALSE;
    $old_set = $current_set;
277
    while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
278
      $current_set = &_batch_current_set();
279
      $current_set['start'] = microtime(TRUE);
280
      $set_changed = TRUE;
281
    }
282

283 284
    // At this point, either $current_set contains operations that need to be
    // processed or all sets have been completed.
285
    $queue = _batch_queue($current_set);
286

287
    // If we are in progressive mode, break processing after 1 second.
288
    if ($batch['progressive'] && Timer::read('batch_processing') > 1000) {
Dries's avatar
Dries committed
289
      // Record elapsed wall clock time.
290
      $current_set['elapsed'] = round((microtime(TRUE) - $current_set['start']) * 1000, 2);
291 292 293 294 295
      break;
    }
  }

  if ($batch['progressive']) {
296 297 298 299
    // Gather progress information.

    // Reporting 100% progress will cause the whole batch to be considered
    // processed. If processing was paused right after moving to a new set,
300
    // we have to use the info from the new (unprocessed) set.
301
    if ($set_changed && isset($current_set['queue'])) {
302
      // Processing will continue with a fresh batch set.
303
      $remaining        = $current_set['count'];
304
      $total            = $current_set['total'];
305
      $progress_message = $current_set['init_message'];
306
      $task_message     = '';
307 308
    }
    else {
309
      // Processing will continue with the current batch set.
310
      $remaining        = $old_set['count'];
311
      $total            = $old_set['total'];
312 313 314
      $progress_message = $old_set['progress_message'];
    }

315 316
    // Total progress is the number of operations that have fully run plus the
    // completion level of the current operation.
317
    $current    = $total - $remaining + $finished;
318
    $percentage = _batch_api_percentage($total, $current);
319
    $elapsed    = isset($current_set['elapsed']) ? $current_set['elapsed'] : 0;
320
    $values     = array(
321 322 323 324
      '@remaining'  => $remaining,
      '@total'      => $total,
      '@current'    => floor($current),
      '@percentage' => $percentage,
325
      '@elapsed'    => \Drupal::service('date.formatter')->formatInterval($elapsed / 1000),
326
      // If possible, estimate remaining processing time.
327
      '@estimate'   => ($current > 0) ? \Drupal::service('date.formatter')->formatInterval(($elapsed * ($total - $current) / $current) / 1000) : '-',
328 329
    );
    $message = strtr($progress_message, $values);
330
    if (!empty($task_message)) {
331
      $label = $task_message;
332
    }
333

334
    return array($percentage, $message, $label);
335 336
  }
  else {
337
    // If we are not in progressive mode, the entire batch has been processed.
338 339 340 341
    return _batch_finished();
  }
}

342
/**
343
 * Formats the percent completion for a batch set.
344 345 346 347
 *
 * @param $total
 *   The total number of operations.
 * @param $current
348 349 350 351
 *   The number of the current operation. This may be a floating point number
 *   rather than an integer in the case of a multi-step operation that is not
 *   yet complete; in that case, the fractional part of $current represents the
 *   fraction of the operation that has been completed.
352
 *
353 354 355 356 357
 * @return
 *   The properly formatted percentage, as a string. We output percentages
 *   using the correct number of decimal places so that we never print "100%"
 *   until we are finished, but we also never print more decimal places than
 *   are meaningful.
358 359
 *
 * @see _batch_process()
360 361
 */
function _batch_api_percentage($total, $current) {
362
  return Percentage::format($total, $current);
363 364
}

365
/**
366
 * Returns the batch set being currently processed.
367 368
 */
function &_batch_current_set() {
369
  $batch = &batch_get();
370 371 372 373
  return $batch['sets'][$batch['current_set']];
}

/**
374
 * Retrieves the next set in a batch.
375 376 377 378 379 380 381
 *
 * If there is a subsequent set in this batch, assign it as the new set to
 * process and execute its form submit handler (if defined), which may add
 * further sets to this batch.
 *
 * @return
 *   TRUE if a subsequent set was found in the batch.
382 383
 */
function _batch_next_set() {
384
  $batch = &batch_get();
385
  if (isset($batch['sets'][$batch['current_set'] + 1])) {
386
    $batch['current_set']++;
387
    $current_set = &_batch_current_set();
388
    if (isset($current_set['form_submit']) && ($callback = $current_set['form_submit']) && is_callable($callback)) {
389 390
      // We use our stored copies of $form and $form_state to account for
      // possible alterations by previous form submit handlers.
391
      call_user_func_array($callback, array(&$batch['form_state']['complete_form'], &$batch['form_state']));
392 393 394 395 396 397
    }
    return TRUE;
  }
}

/**
398
 * Ends the batch processing.
399 400 401
 *
 * Call the 'finished' callback of each batch set to allow custom handling of
 * the results and resolve page redirection.
402 403
 */
function _batch_finished() {
404
  $batch = &batch_get();
405

406
  // Execute the 'finished' callbacks for each batch set, if defined.
407
  foreach ($batch['sets'] as $batch_set) {
408
    if (isset($batch_set['finished'])) {
409
      // Check if the set requires an additional file for function definitions.
410
      if (isset($batch_set['file']) && is_file($batch_set['file'])) {
411
        include_once DRUPAL_ROOT . '/' . $batch_set['file'];
412
      }
413
      if (is_callable($batch_set['finished'])) {
414 415
        $queue = _batch_queue($batch_set);
        $operations = $queue->getAllItems();
416
        call_user_func_array($batch_set['finished'], array($batch_set['success'], $batch_set['results'], $operations, \Drupal::service('date.formatter')->formatInterval($batch_set['elapsed'] / 1000)));
417
      }
418 419 420
    }
  }

421
  // Clean up the batch table and unset the static $batch variable.
422
  if ($batch['progressive']) {
423
    \Drupal::service('batch.storage')->delete($batch['id']);
424 425 426 427 428
    foreach ($batch['sets'] as $batch_set) {
      if ($queue = _batch_queue($batch_set)) {
        $queue->deleteQueue();
      }
    }
429 430 431 432 433 434 435
    // Clean-up the session. Not needed for CLI updates.
    if (isset($_SESSION)) {
      unset($_SESSION['batches'][$batch['id']]);
      if (empty($_SESSION['batches'])) {
        unset($_SESSION['batches']);
      }
    }
436
  }
437 438 439 440 441
  $_batch = $batch;
  $batch = NULL;

  // Redirect if needed.
  if ($_batch['progressive']) {
442
    // Revert the 'destination' that was saved in batch_process().
443
    if (isset($_batch['destination'])) {
444
      \Drupal::request()->query->set('destination', $_batch['destination']);
445
    }
446

447
    // Determine the target path to redirect to.
448 449 450
    if (!isset($_batch['form_state'])) {
      $_batch['form_state'] = new FormState();
    }
451 452 453 454 455
    if ($_batch['form_state']->getRedirect() === NULL) {
      $redirect = $_batch['batch_redirect'] ?: $_batch['source_url'];
      $options = UrlHelper::parse($redirect);
      if (!UrlHelper::isExternal($options['path'])) {
        $options['path'] = $GLOBALS['base_url'] . '/' . $options['path'];
456
      }
457 458 459
      $redirect = Url::createFromPath($options['path']);
      $redirect->setOptions($options);
      $_batch['form_state']->setRedirectUrl($redirect);
460 461
    }

462 463 464
    // Use \Drupal\Core\Form\FormSubmitterInterface::redirectForm() to handle
    // the redirection logic.
    $redirect = \Drupal::service('form_submitter')->redirectForm($_batch['form_state']);
465 466 467
    if (is_object($redirect)) {
      return $redirect;
    }
468

469 470
    // If no redirection happened, redirect to the originating page. In case the
    // form needs to be rebuilt, save the final $form_state for
471
    // \Drupal\Core\Form\FormBuilderInterface::buildForm().
472 473 474
    if (!empty($_batch['form_state']['rebuild'])) {
      $_SESSION['batch_form_state'] = $_batch['form_state'];
    }
475 476 477
    $callback = $_batch['redirect_callback'];
    if (is_callable($callback)) {
      $callback($_batch['source_url'], array('query' => array('op' => 'finish', 'id' => $_batch['id'])));
478
    }
479
    elseif ($callback === NULL) {
480 481 482 483 484 485 486
      // Default to RedirectResponse objects when nothing specified.
      $url = url($_batch['source_url'], array(
        'absolute' => TRUE,
        'query' => array('op' => 'finish', 'id' => $_batch['id']),
      ));
      return new RedirectResponse($url);
    }
487 488 489 490
  }
}

/**
491 492 493 494
 * Shutdown function: Stores the current batch data for the next request.
 *
 * @see _batch_page()
 * @see drupal_register_shutdown_function()
495 496 497
 */
function _batch_shutdown() {
  if ($batch = batch_get()) {
498 499 500 501
    // Update safe strings.
    // @todo Ensure we are not storing an excessively large string list in:
    //   https://www.drupal.org/node/2295823
    $batch['safe_strings'] = SafeMarkup::getAll();
502
    \Drupal::service('batch.storage')->update($batch);
503 504
  }
}