batch.inc 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  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. /**
  16. * Loads a batch from the database.
  17. *
  18. * @param $id
  19. * The ID of the batch to load. When a progressive batch is being processed,
  20. * the relevant ID is found in $_REQUEST['id'].
  21. *
  22. * @return
  23. * An array representing the batch, or FALSE if no batch was found.
  24. */
  25. function batch_load($id) {
  26. $batch = db_query("SELECT batch FROM {batch} WHERE bid = :bid AND token = :token", array(
  27. ':bid' => $id,
  28. ':token' => drupal_get_token($id),
  29. ))->fetchField();
  30. if ($batch) {
  31. return unserialize($batch);
  32. }
  33. return FALSE;
  34. }
  35. /**
  36. * Renders the batch processing page based on the current state of the batch.
  37. *
  38. * @see _batch_shutdown()
  39. */
  40. function _batch_page() {
  41. $batch = &batch_get();
  42. if (!isset($_REQUEST['id'])) {
  43. return FALSE;
  44. }
  45. // Retrieve the current state of the batch.
  46. if (!$batch) {
  47. $batch = batch_load($_REQUEST['id']);
  48. if (!$batch) {
  49. drupal_set_message(t('No active batch.'), 'error');
  50. drupal_goto();
  51. }
  52. }
  53. // Register database update for the end of processing.
  54. drupal_register_shutdown_function('_batch_shutdown');
  55. // Add batch-specific CSS.
  56. foreach ($batch['sets'] as $batch_set) {
  57. if (isset($batch_set['css'])) {
  58. foreach ($batch_set['css'] as $css) {
  59. drupal_add_css($css);
  60. }
  61. }
  62. }
  63. $op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
  64. $output = NULL;
  65. switch ($op) {
  66. case 'start':
  67. $output = _batch_start();
  68. break;
  69. case 'do':
  70. // JavaScript-based progress page callback.
  71. _batch_do();
  72. break;
  73. case 'do_nojs':
  74. // Non-JavaScript-based progress page.
  75. $output = _batch_progress_page_nojs();
  76. break;
  77. case 'finished':
  78. $output = _batch_finished();
  79. break;
  80. }
  81. return $output;
  82. }
  83. /**
  84. * Initializes the batch processing.
  85. *
  86. * JavaScript-enabled clients are identified by the 'has_js' cookie set in
  87. * drupal.js. If no JavaScript-enabled page has been visited during the current
  88. * user's browser session, the non-JavaScript version is returned.
  89. */
  90. function _batch_start() {
  91. if (isset($_COOKIE['has_js']) && $_COOKIE['has_js']) {
  92. return _batch_progress_page_js();
  93. }
  94. else {
  95. return _batch_progress_page_nojs();
  96. }
  97. }
  98. /**
  99. * Outputs a batch processing page with JavaScript support.
  100. *
  101. * This initializes the batch and error messages. Note that in JavaScript-based
  102. * processing, the batch processing page is displayed only once and updated via
  103. * AHAH requests, so only the first batch set gets to define the page title.
  104. * Titles specified by subsequent batch sets are not displayed.
  105. *
  106. * @see batch_set()
  107. * @see _batch_do()
  108. */
  109. function _batch_progress_page_js() {
  110. $batch = batch_get();
  111. $current_set = _batch_current_set();
  112. drupal_set_title($current_set['title'], PASS_THROUGH);
  113. // Merge required query parameters for batch processing into those provided by
  114. // batch_set() or hook_batch_alter().
  115. $batch['url_options']['query']['id'] = $batch['id'];
  116. $js_setting = array(
  117. 'batch' => array(
  118. 'errorMessage' => $current_set['error_message'] . '<br />' . $batch['error_message'],
  119. 'initMessage' => $current_set['init_message'],
  120. 'uri' => url($batch['url'], $batch['url_options']),
  121. ),
  122. );
  123. drupal_add_js($js_setting, 'setting');
  124. drupal_add_library('system', 'drupal.batch');
  125. return '<div id="progress"></div>';
  126. }
  127. /**
  128. * Does one execution pass with JavaScript and returns progress to the browser.
  129. *
  130. * @see _batch_progress_page_js()
  131. * @see _batch_process()
  132. */
  133. function _batch_do() {
  134. // HTTP POST required.
  135. if ($_SERVER['REQUEST_METHOD'] != 'POST') {
  136. drupal_set_message(t('HTTP POST is required.'), 'error');
  137. drupal_set_title(t('Error'));
  138. return '';
  139. }
  140. // Perform actual processing.
  141. list($percentage, $message) = _batch_process();
  142. drupal_json_output(array('status' => TRUE, 'percentage' => $percentage, 'message' => $message));
  143. }
  144. /**
  145. * Outputs a batch processing page without JavaScript support.
  146. *
  147. * @see _batch_process()
  148. */
  149. function _batch_progress_page_nojs() {
  150. $batch = &batch_get();
  151. $current_set = _batch_current_set();
  152. drupal_set_title($current_set['title'], PASS_THROUGH);
  153. $new_op = 'do_nojs';
  154. if (!isset($batch['running'])) {
  155. // This is the first page so we return some output immediately.
  156. $percentage = 0;
  157. $message = $current_set['init_message'];
  158. $batch['running'] = TRUE;
  159. }
  160. else {
  161. // This is one of the later requests; do some processing first.
  162. // Error handling: if PHP dies due to a fatal error (e.g. a nonexistent
  163. // function), it will output whatever is in the output buffer, followed by
  164. // the error message.
  165. ob_start();
  166. $fallback = $current_set['error_message'] . '<br />' . $batch['error_message'];
  167. $fallback = theme('maintenance_page', array('content' => $fallback, 'show_messages' => FALSE));
  168. // We strip the end of the page using a marker in the template, so any
  169. // additional HTML output by PHP shows up inside the page rather than below
  170. // it. While this causes invalid HTML, the same would be true if we didn't,
  171. // as content is not allowed to appear after </html> anyway.
  172. list($fallback) = explode('<!--partial-->', $fallback);
  173. print $fallback;
  174. // Perform actual processing.
  175. list($percentage, $message) = _batch_process($batch);
  176. if ($percentage == 100) {
  177. $new_op = 'finished';
  178. }
  179. // PHP did not die; remove the fallback output.
  180. ob_end_clean();
  181. }
  182. // Merge required query parameters for batch processing into those provided by
  183. // batch_set() or hook_batch_alter().
  184. $batch['url_options']['query']['id'] = $batch['id'];
  185. $batch['url_options']['query']['op'] = $new_op;
  186. $url = url($batch['url'], $batch['url_options']);
  187. $element = array(
  188. '#tag' => 'meta',
  189. '#attributes' => array(
  190. 'http-equiv' => 'Refresh',
  191. 'content' => '0; URL=' . $url,
  192. ),
  193. );
  194. drupal_add_html_head($element, 'batch_progress_meta_refresh');
  195. return theme('progress_bar', array('percent' => $percentage, 'message' => $message));
  196. }
  197. /**
  198. * Processes sets in a batch.
  199. *
  200. * If the batch was marked for progressive execution (default), this executes as
  201. * many operations in batch sets until an execution time of 1 second has been
  202. * exceeded. It will continue with the next operation of the same batch set in
  203. * the next request.
  204. *
  205. * @return
  206. * An array containing a completion value (in percent) and a status message.
  207. */
  208. function _batch_process() {
  209. $batch = &batch_get();
  210. $current_set = &_batch_current_set();
  211. // Indicate that this batch set needs to be initialized.
  212. $set_changed = TRUE;
  213. // If this batch was marked for progressive execution (e.g. forms submitted by
  214. // drupal_form_submit()), initialize a timer to determine whether we need to
  215. // proceed with the same batch phase when a processing time of 1 second has
  216. // been exceeded.
  217. if ($batch['progressive']) {
  218. timer_start('batch_processing');
  219. }
  220. if (empty($current_set['start'])) {
  221. $current_set['start'] = microtime(TRUE);
  222. }
  223. $queue = _batch_queue($current_set);
  224. while (!$current_set['success']) {
  225. // If this is the first time we iterate this batch set in the current
  226. // request, we check if it requires an additional file for functions
  227. // definitions.
  228. if ($set_changed && isset($current_set['file']) && is_file($current_set['file'])) {
  229. include_once DRUPAL_ROOT . '/' . $current_set['file'];
  230. }
  231. $task_message = '';
  232. // Assume a single pass operation and set the completion level to 1 by
  233. // default.
  234. $finished = 1;
  235. if ($item = $queue->claimItem()) {
  236. list($function, $args) = $item->data;
  237. // Build the 'context' array and execute the function call.
  238. $batch_context = array(
  239. 'sandbox' => &$current_set['sandbox'],
  240. 'results' => &$current_set['results'],
  241. 'finished' => &$finished,
  242. 'message' => &$task_message,
  243. );
  244. call_user_func_array($function, array_merge($args, array(&$batch_context)));
  245. if ($finished >= 1) {
  246. // Make sure this step is not counted twice when computing $current.
  247. $finished = 0;
  248. // Remove the processed operation and clear the sandbox.
  249. $queue->deleteItem($item);
  250. $current_set['count']--;
  251. $current_set['sandbox'] = array();
  252. }
  253. }
  254. // When all operations in the current batch set are completed, browse
  255. // through the remaining sets, marking them 'successfully processed'
  256. // along the way, until we find a set that contains operations.
  257. // _batch_next_set() executes form submit handlers stored in 'control'
  258. // sets (see form_execute_handlers()), which can in turn add new sets to
  259. // the batch.
  260. $set_changed = FALSE;
  261. $old_set = $current_set;
  262. while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
  263. $current_set = &_batch_current_set();
  264. $current_set['start'] = microtime(TRUE);
  265. $set_changed = TRUE;
  266. }
  267. // At this point, either $current_set contains operations that need to be
  268. // processed or all sets have been completed.
  269. $queue = _batch_queue($current_set);
  270. // If we are in progressive mode, break processing after 1 second.
  271. if ($batch['progressive'] && timer_read('batch_processing') > 1000) {
  272. // Record elapsed wall clock time.
  273. $current_set['elapsed'] = round((microtime(TRUE) - $current_set['start']) * 1000, 2);
  274. break;
  275. }
  276. }
  277. if ($batch['progressive']) {
  278. // Gather progress information.
  279. // Reporting 100% progress will cause the whole batch to be considered
  280. // processed. If processing was paused right after moving to a new set,
  281. // we have to use the info from the new (unprocessed) set.
  282. if ($set_changed && isset($current_set['queue'])) {
  283. // Processing will continue with a fresh batch set.
  284. $remaining = $current_set['count'];
  285. $total = $current_set['total'];
  286. $progress_message = $current_set['init_message'];
  287. $task_message = '';
  288. }
  289. else {
  290. // Processing will continue with the current batch set.
  291. $remaining = $old_set['count'];
  292. $total = $old_set['total'];
  293. $progress_message = $old_set['progress_message'];
  294. }
  295. // Total progress is the number of operations that have fully run plus the
  296. // completion level of the current operation.
  297. $current = $total - $remaining + $finished;
  298. $percentage = _batch_api_percentage($total, $current);
  299. $elapsed = isset($current_set['elapsed']) ? $current_set['elapsed'] : 0;
  300. $values = array(
  301. '@remaining' => $remaining,
  302. '@total' => $total,
  303. '@current' => floor($current),
  304. '@percentage' => $percentage,
  305. '@elapsed' => format_interval($elapsed / 1000),
  306. // If possible, estimate remaining processing time.
  307. '@estimate' => ($current > 0) ? format_interval(($elapsed * ($total - $current) / $current) / 1000) : '-',
  308. );
  309. $message = strtr($progress_message, $values);
  310. if (!empty($message)) {
  311. $message .= '<br />';
  312. }
  313. if (!empty($task_message)) {
  314. $message .= $task_message;
  315. }
  316. return array($percentage, $message);
  317. }
  318. else {
  319. // If we are not in progressive mode, the entire batch has been processed.
  320. return _batch_finished();
  321. }
  322. }
  323. /**
  324. * Formats the percent completion for a batch set.
  325. *
  326. * @param $total
  327. * The total number of operations.
  328. * @param $current
  329. * The number of the current operation. This may be a floating point number
  330. * rather than an integer in the case of a multi-step operation that is not
  331. * yet complete; in that case, the fractional part of $current represents the
  332. * fraction of the operation that has been completed.
  333. *
  334. * @return
  335. * The properly formatted percentage, as a string. We output percentages
  336. * using the correct number of decimal places so that we never print "100%"
  337. * until we are finished, but we also never print more decimal places than
  338. * are meaningful.
  339. *
  340. * @see _batch_process()
  341. */
  342. function _batch_api_percentage($total, $current) {
  343. if (!$total || $total == $current) {
  344. // If $total doesn't evaluate as true or is equal to the current set, then
  345. // we're finished, and we can return "100".
  346. $percentage = "100";
  347. }
  348. else {
  349. // We add a new digit at 200, 2000, etc. (since, for example, 199/200
  350. // would round up to 100% if we didn't).
  351. $decimal_places = max(0, floor(log10($total / 2.0)) - 1);
  352. do {
  353. // Calculate the percentage to the specified number of decimal places.
  354. $percentage = sprintf('%01.' . $decimal_places . 'f', round($current / $total * 100, $decimal_places));
  355. // When $current is an integer, the above calculation will always be
  356. // correct. However, if $current is a floating point number (in the case
  357. // of a multi-step batch operation that is not yet complete), $percentage
  358. // may be erroneously rounded up to 100%. To prevent that, we add one
  359. // more decimal place and try again.
  360. $decimal_places++;
  361. } while ($percentage == '100');
  362. }
  363. return $percentage;
  364. }
  365. /**
  366. * Returns the batch set being currently processed.
  367. */
  368. function &_batch_current_set() {
  369. $batch = &batch_get();
  370. return $batch['sets'][$batch['current_set']];
  371. }
  372. /**
  373. * Retrieves the next set in a batch.
  374. *
  375. * If there is a subsequent set in this batch, assign it as the new set to
  376. * process and execute its form submit handler (if defined), which may add
  377. * further sets to this batch.
  378. *
  379. * @return
  380. * TRUE if a subsequent set was found in the batch.
  381. */
  382. function _batch_next_set() {
  383. $batch = &batch_get();
  384. if (isset($batch['sets'][$batch['current_set'] + 1])) {
  385. $batch['current_set']++;
  386. $current_set = &_batch_current_set();
  387. if (isset($current_set['form_submit']) && ($function = $current_set['form_submit']) && function_exists($function)) {
  388. // We use our stored copies of $form and $form_state to account for
  389. // possible alterations by previous form submit handlers.
  390. $function($batch['form_state']['complete form'], $batch['form_state']);
  391. }
  392. return TRUE;
  393. }
  394. }
  395. /**
  396. * Ends the batch processing.
  397. *
  398. * Call the 'finished' callback of each batch set to allow custom handling of
  399. * the results and resolve page redirection.
  400. */
  401. function _batch_finished() {
  402. $batch = &batch_get();
  403. // Execute the 'finished' callbacks for each batch set, if defined.
  404. foreach ($batch['sets'] as $batch_set) {
  405. if (isset($batch_set['finished'])) {
  406. // Check if the set requires an additional file for function definitions.
  407. if (isset($batch_set['file']) && is_file($batch_set['file'])) {
  408. include_once DRUPAL_ROOT . '/' . $batch_set['file'];
  409. }
  410. if (is_callable($batch_set['finished'])) {
  411. $queue = _batch_queue($batch_set);
  412. $operations = $queue->getAllItems();
  413. call_user_func($batch_set['finished'], $batch_set['success'], $batch_set['results'], $operations, format_interval($batch_set['elapsed'] / 1000));
  414. }
  415. }
  416. }
  417. // Clean up the batch table and unset the static $batch variable.
  418. if ($batch['progressive']) {
  419. db_delete('batch')
  420. ->condition('bid', $batch['id'])
  421. ->execute();
  422. foreach ($batch['sets'] as $batch_set) {
  423. if ($queue = _batch_queue($batch_set)) {
  424. $queue->deleteQueue();
  425. }
  426. }
  427. // Clean-up the session. Not needed for CLI updates.
  428. if (isset($_SESSION)) {
  429. unset($_SESSION['batches'][$batch['id']]);
  430. if (empty($_SESSION['batches'])) {
  431. unset($_SESSION['batches']);
  432. }
  433. }
  434. }
  435. $_batch = $batch;
  436. $batch = NULL;
  437. // Redirect if needed.
  438. if ($_batch['progressive']) {
  439. // Revert the 'destination' that was saved in batch_process().
  440. if (isset($_batch['destination'])) {
  441. $_GET['destination'] = $_batch['destination'];
  442. }
  443. // Determine the target path to redirect to.
  444. if (!isset($_batch['form_state']['redirect'])) {
  445. if (isset($_batch['redirect'])) {
  446. $_batch['form_state']['redirect'] = $_batch['redirect'];
  447. }
  448. else {
  449. $_batch['form_state']['redirect'] = $_batch['source_url'];
  450. }
  451. }
  452. // Use drupal_redirect_form() to handle the redirection logic.
  453. drupal_redirect_form($_batch['form_state']);
  454. // If no redirection happened, redirect to the originating page. In case the
  455. // form needs to be rebuilt, save the final $form_state for
  456. // drupal_build_form().
  457. if (!empty($_batch['form_state']['rebuild'])) {
  458. $_SESSION['batch_form_state'] = $_batch['form_state'];
  459. }
  460. $function = $_batch['redirect_callback'];
  461. if (function_exists($function)) {
  462. $function($_batch['source_url'], array('query' => array('op' => 'finish', 'id' => $_batch['id'])));
  463. }
  464. }
  465. }
  466. /**
  467. * Shutdown function: Stores the current batch data for the next request.
  468. *
  469. * @see _batch_page()
  470. * @see drupal_register_shutdown_function()
  471. */
  472. function _batch_shutdown() {
  473. if ($batch = batch_get()) {
  474. db_update('batch')
  475. ->fields(array('batch' => serialize($batch)))
  476. ->condition('bid', $batch['id'])
  477. ->execute();
  478. }
  479. }