From 0dd161277046bab1ec994e8d756c4e99c717421e Mon Sep 17 00:00:00 2001
From: Angie Byron <webchick@24967.no-reply.drupal.org>
Date: Fri, 8 Jan 2010 06:36:34 +0000
Subject: [PATCH] #629794 by yched: Fix Scaling issues with batch API. (with
 tests)

---
 includes/batch.inc                            |  81 +--
 includes/batch.queue.inc                      |  72 +++
 includes/form.inc                             | 177 +++++--
 includes/update.inc                           |  45 ++
 modules/simpletest/tests/batch.test           | 285 ++++++++++-
 .../simpletest/tests/batch_test.callbacks.inc | 118 +++++
 modules/simpletest/tests/batch_test.info      |   9 +
 modules/simpletest/tests/batch_test.module    | 475 ++++++++++++++++++
 modules/simpletest/tests/form.test            |  39 --
 modules/simpletest/tests/form_test.module     |  68 ---
 modules/simpletest/tests/system_test.module   |  33 --
 modules/system/system.install                 |  56 +--
 modules/system/system.module                  |  13 +-
 modules/system/system.queue.inc               |  73 +++
 14 files changed, 1262 insertions(+), 282 deletions(-)
 create mode 100644 includes/batch.queue.inc
 create mode 100644 modules/simpletest/tests/batch_test.callbacks.inc
 create mode 100644 modules/simpletest/tests/batch_test.info
 create mode 100644 modules/simpletest/tests/batch_test.module

diff --git a/includes/batch.inc b/includes/batch.inc
index c0a7c96e02e8..850eff7103db 100644
--- a/includes/batch.inc
+++ b/includes/batch.inc
@@ -6,7 +6,7 @@
  * @file
  * Batch processing API for processes to run in multiple HTTP requests.
  *
- * Please note that batches are usually invoked by form submissions, which is
+ * Note that batches are usually invoked by form submissions, which is
  * why the core interaction functions of the batch processing API live in
  * form.inc.
  *
@@ -62,8 +62,10 @@ function _batch_page() {
 
   // Add batch-specific CSS.
   foreach ($batch['sets'] as $batch_set) {
-    foreach ($batch_set['css'] as $css) {
-      drupal_add_css($css);
+    if (isset($batch_set['css'])) {
+      foreach ($batch_set['css'] as $css) {
+        drupal_add_css($css);
+      }
     }
   }
 
@@ -252,6 +254,12 @@ function _batch_process() {
     timer_start('batch_processing');
   }
 
+  if (empty($current_set['start'])) {
+    $current_set['start'] = microtime(TRUE);
+  }
+
+  $queue = _batch_queue($current_set);
+
   while (!$current_set['success']) {
     // 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
@@ -261,42 +269,49 @@ function _batch_process() {
     }
 
     $task_message = '';
-    // We assume a single pass operation and set the completion level to 1 by
+    // Assume a single pass operation and set the completion level to 1 by
     // default.
     $finished = 1;
-    if ((list($function, $args) = reset($current_set['operations'])) && function_exists($function)) {
-      // Build the 'context' array, execute the function call, and retrieve the
-      // user message.
+
+    if ($item = $queue->claimItem()) {
+      list($function, $args) = $item->data;
+
+      // Build the 'context' array and execute the function call.
       $batch_context = array(
         'sandbox'  => &$current_set['sandbox'],
         'results'  => &$current_set['results'],
         'finished' => &$finished,
         'message'  => &$task_message,
       );
-      // Process the current operation.
       call_user_func_array($function, array_merge($args, array(&$batch_context)));
-    }
 
-    if ($finished == 1) {
-      // Make sure this step is not counted twice when computing $current.
-      $finished = 0;
-      // Remove the processed operation and clear the sandbox.
-      array_shift($current_set['operations']);
-      $current_set['sandbox'] = array();
+      if ($finished == 1) {
+        // 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();
+      }
     }
 
     // When all operations in the current batch set are completed, browse
-    // through the remaining sets until we find a set that contains operations.
-    // Note that _batch_next_set() executes stored form submit handlers in
-    // remaining batch sets, which can add new sets to the batch.
+    // 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.
     $set_changed = FALSE;
     $old_set = $current_set;
-    while (empty($current_set['operations']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
+    while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
       $current_set = &_batch_current_set();
+      $current_set['start'] = microtime(TRUE);
       $set_changed = TRUE;
     }
+
     // At this point, either $current_set contains operations that need to be
     // processed or all sets have been completed.
+    $queue = _batch_queue($current_set);
 
     // If we are in progressive mode, break processing after 1 second.
     if ($batch['progressive'] && timer_read('batch_processing') > 1000) {
@@ -312,33 +327,31 @@ function _batch_process() {
     // Reporting 100% progress will cause the whole batch to be considered
     // processed. If processing was paused right after moving to a new set,
     // we have to use the info from the new (unprocessed) set.
-    if ($set_changed && isset($current_set['operations'])) {
+    if ($set_changed && isset($current_set['queue'])) {
       // Processing will continue with a fresh batch set.
-      $remaining        = count($current_set['operations']);
+      $remaining        = $current_set['count'];
       $total            = $current_set['total'];
       $progress_message = $current_set['init_message'];
       $task_message     = '';
     }
     else {
       // Processing will continue with the current batch set.
-      $remaining        = count($old_set['operations']);
+      $remaining        = $old_set['count'];
       $total            = $old_set['total'];
       $progress_message = $old_set['progress_message'];
     }
 
-    $current = $total - $remaining + $finished;
+    $current    = $total - $remaining + $finished;
     $percentage = _batch_api_percentage($total, $current);
-
     $elapsed    = $current_set['elapsed'];
-    // Estimate remaining with percentage in floating format.
-    $estimate   = $elapsed * ($total - $current) / $current;
     $values     = array(
       '@remaining'  => $remaining,
       '@total'      => $total,
       '@current'    => floor($current),
       '@percentage' => $percentage,
       '@elapsed'    => format_interval($elapsed / 1000),
-      '@estimate'   => format_interval($estimate / 1000),
+      // If possible, estimate remaining processing time.
+      '@estimate'   => ($current > 0) ? format_interval(($elapsed * ($total - $current) / $current) / 1000) : '-',
     );
     $message = strtr($progress_message, $values);
     if (!empty($message)) {
@@ -410,7 +423,7 @@ function _batch_next_set() {
     if (isset($current_set['form_submit']) && ($function = $current_set['form_submit']) && function_exists($function)) {
       // We use our stored copies of $form and $form_state to account for
       // possible alterations by previous form submit handlers.
-      $function($batch['form'], $batch['form_state']);
+      $function($batch['form_state']['complete form'], $batch['form_state']);
     }
     return TRUE;
   }
@@ -426,15 +439,16 @@ function _batch_finished() {
   $batch = &batch_get();
 
   // Execute the 'finished' callbacks for each batch set, if defined.
-  foreach ($batch['sets'] as $key => $batch_set) {
+  foreach ($batch['sets'] as $batch_set) {
     if (isset($batch_set['finished'])) {
       // Check if the set requires an additional file for function definitions.
       if (isset($batch_set['file']) && is_file($batch_set['file'])) {
         include_once DRUPAL_ROOT . '/' . $batch_set['file'];
       }
       if (function_exists($batch_set['finished'])) {
-        // Format the elapsed time when batch complete.
-        $batch_set['finished']($batch_set['success'], $batch_set['results'], $batch_set['operations'], format_interval($batch_set['elapsed'] / 1000));
+        $queue = _batch_queue($batch_set);
+        $operations = $queue->getAllItems();
+        $batch_set['finished']($batch_set['success'], $batch_set['results'], $operations, format_interval($batch_set['elapsed'] / 1000));
       }
     }
   }
@@ -444,6 +458,11 @@ function _batch_finished() {
     db_delete('batch')
       ->condition('bid', $batch['id'])
       ->execute();
+    foreach ($batch['sets'] as $batch_set) {
+      if ($queue = _batch_queue($batch_set)) {
+        $queue->deleteQueue();
+      }
+    }
   }
   $_batch = $batch;
   $batch = NULL;
diff --git a/includes/batch.queue.inc b/includes/batch.queue.inc
new file mode 100644
index 000000000000..8193280f3cf1
--- /dev/null
+++ b/includes/batch.queue.inc
@@ -0,0 +1,72 @@
+<?php
+// $Id$
+
+
+/**
+ * @file
+ * Queue handlers used by the Batch API.
+ *
+ * Those implementations:
+ * - ensure FIFO ordering,
+ * - let an item be repeatedly claimed until it is actually deleted (no notion
+ *   of lease time or 'expire' date), to allow multipass operations.
+ */
+
+/**
+ * Batch queue implementation.
+ *
+ * Stale items from failed batches are cleaned from the {queue} table on cron
+ * using the 'created' date.
+ */
+class BatchQueue extends SystemQueue {
+
+  public function claimItem($lease_time = 0) {
+    $item = db_query('SELECT data, item_id FROM {queue} q WHERE name = :name ORDER BY item_id ASC', array(':name' => $this->name))->fetchObject();
+    if ($item) {
+      $item->data = unserialize($item->data);
+      return $item;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Retrieve all remaining items in the queue.
+   *
+   * This is specific to Batch API and is not part of the DrupalQueueInterface,
+   */
+  public function getAllItems() {
+    $result = array();
+    $items = db_query('SELECT data FROM {queue} q WHERE name = :name ORDER BY item_id ASC', array(':name' => $this->name))->fetchAll();
+    foreach ($items as $item) {
+      $result[] = unserialize($item->data);
+    }
+    return $result;
+  }
+}
+
+/**
+ * Batch queue implementation used for non-progressive batches.
+ */
+class BatchMemoryQueue extends MemoryQueue {
+
+  public function claimItem($lease_time = 0) {
+    if (!empty($this->queue)) {
+      reset($this->queue);
+      return current($this->queue);
+    }
+    return FALSE;
+  }
+
+  /**
+   * Retrieve all remaining items in the queue.
+   *
+   * This is specific to Batch API and is not part of the DrupalQueueInterface,
+   */
+  public function getAllItems() {
+    $result = array();
+    foreach ($this->queue as $item) {
+      $result[] = $item->data;
+    }
+    return $result;
+  }
+}
diff --git a/includes/form.inc b/includes/form.inc
index 753f421e8170..f9f0a27df42e 100644
--- a/includes/form.inc
+++ b/includes/form.inc
@@ -634,12 +634,23 @@ function drupal_process_form($form_id, &$form, &$form_state) {
       // that is already being processed (if a batch operation performs a
       // drupal_form_submit).
       if ($batch =& batch_get() && !isset($batch['current_set'])) {
-        // The batch uses its own copies of $form and $form_state for
-        // late execution of submit handlers and post-batch redirection.
-        $batch['form'] = $form;
-        $batch['form_state'] = $form_state;
+        // Store $form_state information in the batch definition.
+        // We need the full $form_state when either:
+        // - Some submit handlers were saved to be called during batch
+        //   processing. See form_execute_handlers().
+        // - The form is multistep.
+        // In other cases, we only need the information expected by
+        // drupal_redirect_form().
+        if ($batch['has_form_submits'] || !empty($form_state['rebuild']) || !empty($form_state['storage'])) {
+          $batch['form_state'] = $form_state;
+        }
+        else {
+          $batch['form_state'] = array_intersect_key($form_state, array_flip(array('programmed', 'rebuild', 'storage', 'no_redirect', 'redirect')));
+        }
+
         $batch['progressive'] = !$form_state['programmed'];
         batch_process();
+
         // Execution continues only for programmatic forms.
         // For 'regular' forms, we get redirected to the batch processing
         // page. Form redirection will be handled in _batch_finished(),
@@ -1004,14 +1015,15 @@ function form_execute_handlers($type, &$form, &$form_state) {
 
   foreach ($handlers as $function) {
     if (function_exists($function))  {
-      // Check to see if a previous _submit handler has set a batch, but
-      // make sure we do not react to a batch that is already being processed
-      // (for instance if a batch operation performs a drupal_form_submit()).
-      if ($type == 'submit' && ($batch =& batch_get()) && !isset($batch['current_set'])) {
-        // Some previous _submit handler has set a batch. We store the call
-        // in a special 'control' batch set, for execution at the correct
-        // time during the batch processing workflow.
+      // Check if a previous _submit handler has set a batch, but make sure we
+      // do not react to a batch that is already being processed (for instance
+      // if a batch operation performs a drupal_form_submit()).
+      if ($type == 'submit' && ($batch =& batch_get()) && !isset($batch['id'])) {
+        // Some previous submit handler has set a batch. To ensure correct
+        // execution order, store the call in a special 'control' batch set.
+        // See _batch_next_set().
         $batch['sets'][] = array('form_submit' => $function);
+        $batch['has_form_submits'] = TRUE;
       }
       else {
         $function($form, $form_state);
@@ -3305,22 +3317,25 @@ function _form_set_class(&$element, $class = array()) {
 function batch_set($batch_definition) {
   if ($batch_definition) {
     $batch =& batch_get();
-    // Initialize the batch
+
+    // Initialize the batch if needed.
     if (empty($batch)) {
       $batch = array(
         'sets' => array(),
+        'has_form_submits' => FALSE,
       );
     }
 
+    // Base and default properties for the batch set.
+    // Use get_t() to allow batches at install time.
+    $t = get_t();
     $init = array(
       'sandbox' => array(),
       'results' => array(),
       'success' => FALSE,
-      'start' => microtime(TRUE),
+      'start' => 0,
       'elapsed' => 0,
     );
-    // Use get_t() to allow batches at install time.
-    $t = get_t();
     $defaults = array(
       'title' => $t('Processing'),
       'init_message' => $t('Initializing.'),
@@ -3330,20 +3345,29 @@ function batch_set($batch_definition) {
     );
     $batch_set = $init + $batch_definition + $defaults;
 
-    // Tweak init_message to avoid the bottom of the page flickering down after init phase.
+    // Tweak init_message to avoid the bottom of the page flickering down after
+    // init phase.
     $batch_set['init_message'] .= '<br/>&nbsp;';
+
+    // The non-concurrent workflow of batch execution allows us to save
+    // numberOfItems() queries by handling our own counter.
     $batch_set['total'] = count($batch_set['operations']);
+    $batch_set['count'] = $batch_set['total'];
 
-    // If the batch is being processed (meaning we are executing a stored submit handler),
-    // insert the new set after the current one.
-    if (isset($batch['current_set'])) {
-      // array_insert does not exist...
-      $slice1 = array_slice($batch['sets'], 0, $batch['current_set'] + 1);
-      $slice2 = array_slice($batch['sets'], $batch['current_set'] + 1);
-      $batch['sets'] = array_merge($slice1, array($batch_set), $slice2);
+    // Add the set to the batch.
+    if (empty($batch['id'])) {
+      // The batch is not running yet. Simply add the new set.
+      $batch['sets'][] = $batch_set;
     }
     else {
-      $batch['sets'][] = $batch_set;
+      // The set is being added while the batch is running. Insert the new set
+      // right after the current one to ensure execution order, and store its
+      // operations in a queue.
+      $index = $batch['current_set'] + 1;
+      $slice1 = array_slice($batch['sets'], 0, $index);
+      $slice2 = array_slice($batch['sets'], $index);
+      $batch['sets'] = array_merge($slice1, array($batch_set), $slice2);
+      _batch_populate_queue($batch, $index);
     }
   }
 }
@@ -3387,11 +3411,28 @@ function batch_process($redirect = NULL, $url = 'batch', $redirect_callback = 'd
     );
     $batch += $process_info;
 
-    // The batch is now completely built. Allow other modules to make changes to the
-    // batch so that it is easier to reuse batch processes in other enviroments.
+    // The batch is now completely built. Allow other modules to make changes
+    // to the batch so that it is easier to reuse batch processes in other
+    // enviroments.
     drupal_alter('batch', $batch);
 
+    // Assign an arbitrary id: don't rely on a serial column in the 'batch'
+    // table, since non-progressive batches skip database storage completely.
+    $batch['id'] = db_next_id();
+
+    // Move operations to a job queue. Non-progressive batches will use a
+    // memory-based queue.
+    foreach ($batch['sets'] as $key => $batch_set) {
+      _batch_populate_queue($batch, $key);
+    }
+
+    // Initiate processing.
     if ($batch['progressive']) {
+      // Now that we have a batch id, we can generate the redirection link in
+      // the generic error message.
+      $t = get_t();
+      $batch['error_message'] = $t('Please continue to <a href="@error_url">the error page</a>', array('@error_url' => url($url, array('query' => array('id' => $batch['id'], 'op' => 'finished')))));
+
       // Clear the way for the drupal_goto() redirection to the batch processing
       // page, by saving and unsetting the 'destination', if there is any.
       if (isset($_GET['destination'])) {
@@ -3399,24 +3440,11 @@ function batch_process($redirect = NULL, $url = 'batch', $redirect_callback = 'd
         unset($_GET['destination']);
       }
 
-      // Initiate db storage in order to get a batch id. We have to provide
-      // at least an empty string for the (not null) 'token' column.
-      $batch['id'] = db_insert('batch')
+      // Store the batch.
+      db_insert('batch')
         ->fields(array(
-          'token' => '',
+          'bid' => $batch['id'],
           'timestamp' => REQUEST_TIME,
-        ))
-        ->execute();
-
-      // Now that we have a batch id, we can generate the redirection link in
-      // the generic error message.
-      $t = get_t();
-      $batch['error_message'] = $t('Please continue to <a href="@error_url">the error page</a>', array('@error_url' => url($url, array('query' => array('id' => $batch['id'], 'op' => 'finished')))));
-
-      // Actually store the batch data and the token generated form the batch id.
-      db_update('batch')
-        ->condition('bid', $batch['id'])
-        ->fields(array(
           'token' => drupal_get_token($batch['id']),
           'batch' => serialize($batch),
         ))
@@ -3425,6 +3453,7 @@ function batch_process($redirect = NULL, $url = 'batch', $redirect_callback = 'd
       // Set the batch number in the session to guarantee that it will stay alive.
       $_SESSION['batches'][$batch['id']] = TRUE;
 
+      // Redirect for processing.
       $function = $batch['redirect_callback'];
       if (function_exists($function)) {
         $function($batch['url'], array('query' => array('op' => 'start', 'id' => $batch['id'])));
@@ -3453,6 +3482,70 @@ function &batch_get() {
   return $batch;
 }
 
+/**
+ * Populates a job queue with the operations of a batch set.
+ *
+ * Depending on whether the batch is progressive or not, the BatchQueue or
+ * BatchStaticQueue handler classes will be used.
+ *
+ * @param $batch
+ *   The batch array.
+ * @param $set_id
+ *   The id of the set to process.
+ * @return
+ *   The name and class of the queue are added by reference to the batch set.
+ */
+function _batch_populate_queue(&$batch, $set_id) {
+  $batch_set = &$batch['sets'][$set_id];
+
+  if (isset($batch_set['operations'])) {
+    $batch_set += array(
+      'queue' => array(
+        'name' => 'drupal_batch:' . $batch['id'] . ':' . $set_id,
+        'class' => $batch['progressive'] ? 'BatchQueue' : 'BatchMemoryQueue',
+      ),
+    );
+
+    $queue = _batch_queue($batch_set);
+    $queue->createQueue();
+    foreach ($batch_set['operations'] as $operation) {
+      $queue->createItem($operation);
+    }
+
+    unset($batch_set['operations']);
+  }
+}
+
+/**
+ * Returns a queue object for a batch set.
+ *
+ * @param $batch_set
+ *   The batch set.
+ * @return
+ *   The queue object.
+ */
+function _batch_queue($batch_set) {
+  static $queues;
+
+  // The class autoloader is not available when running update.php, so make
+  // sure the files are manually included.
+  if (is_null($queues)) {
+    $queues = array();
+    require_once DRUPAL_ROOT . '/modules/system/system.queue.inc';
+    require_once DRUPAL_ROOT . '/includes/batch.queue.inc';
+  }
+
+  if (isset($batch_set['queue'])) {
+    $name = $batch_set['queue']['name'];
+    $class = $batch_set['queue']['class'];
+
+    if (!isset($queues[$class][$name])) {
+      $queues[$class][$name] = new $class($name);
+    }
+    return $queues[$class][$name];
+  }
+}
+
 /**
  * @} End of "defgroup batch".
  */
diff --git a/includes/update.inc b/includes/update.inc
index 18dd7a8dfa05..816f32bd0af1 100644
--- a/includes/update.inc
+++ b/includes/update.inc
@@ -338,6 +338,51 @@ function update_fix_d7_requirements() {
     db_create_table('date_formats', $schema['date_formats']);
     db_create_table('date_format_locale', $schema['date_format_locale']);
 
+    // Add the queue table.
+    $schema['queue'] = array(
+      'description' => 'Stores items in queues.',
+      'fields' => array(
+        'item_id' => array(
+          'type' => 'serial',
+          'unsigned' => TRUE,
+          'not null' => TRUE,
+          'description' => 'Primary Key: Unique item ID.',
+        ),
+        'name' => array(
+          'type' => 'varchar',
+          'length' => 255,
+          'not null' => TRUE,
+          'default' => '',
+          'description' => 'The queue name.',
+        ),
+        'data' => array(
+          'type' => 'text',
+          'not null' => FALSE,
+          'size' => 'big',
+          'serialize' => TRUE,
+          'description' => 'The arbitrary data for the item.',
+        ),
+        'expire' => array(
+          'type' => 'int',
+          'not null' => TRUE,
+          'default' => 0,
+          'description' => 'Timestamp when the claim lease expires on the item.',
+        ),
+        'created' => array(
+          'type' => 'int',
+          'not null' => TRUE,
+          'default' => 0,
+          'description' => 'Timestamp when the item was created.',
+        ),
+      ),
+      'primary key' => array('item_id'),
+      'indexes' => array(
+        'name_created' => array('name', 'created'),
+        'expire' => array('expire'),
+      ),
+    );
+    db_create_table('queue', $schema['queue']);
+
     // Add column for locale context.
     if (db_table_exists('locales_source')) {
       db_add_field('locales_source', 'context', array('type' => 'varchar', 'length' => 255, 'not null' => TRUE, 'default' => '', 'description' => 'The context this string applies to.'));
diff --git a/modules/simpletest/tests/batch.test b/modules/simpletest/tests/batch.test
index 7c6ade42372e..54b29c8a9a51 100644
--- a/modules/simpletest/tests/batch.test
+++ b/modules/simpletest/tests/batch.test
@@ -3,40 +3,286 @@
 
 /**
  * @file
- * Unit tests for the Drupal Batch API.
+ * Tests for the Batch API.
  */
 
 /**
- * Tests for the batch API progress page theme.
+ * Tests for the Batch API.
  */
-class BatchAPIThemeTestCase extends DrupalWebTestCase {
+class BatchProcessingTestCase extends DrupalWebTestCase {
   public static function getInfo() {
     return array(
-      'name' => 'Batch API progress page theme',
-      'description' => 'Tests that while a progressive batch is running, it correctly uses the theme of the page that started the batch.',
+      'name' => 'Batch processing',
+      'description' => 'Test batch processing in form and non-form workflow.',
       'group' => 'Batch API',
     );
   }
 
   function setUp() {
-    parent::setUp('system_test');
-    // Make sure that the page which starts the batch (an administrative page)
-    // is using a different theme than would normally be used by the batch API.
-    variable_set('theme_default', 'garland');
-    variable_set('admin_theme', 'seven');
+    parent::setUp('batch_test');
+  }
+
+  /**
+   * Test batches triggered outside of form submission.
+   */
+  function testBatchNoForm() {
+    // Displaying the page triggers batch 1.
+    $this->drupalGet('batch_test/no_form');
+    $this->assertBatchMessages($this->_resultMessages(1), t('Batch for step 2 performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+  }
+
+  /**
+   * Test batches defined in a form submit handler.
+   */
+  function testBatchForm() {
+    // Batch 0: no operation.
+    $edit = array('batch' => 'batch_0');
+    $this->drupalPost('batch_test/simple', $edit, 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_0'), t('Batch with no operation performed successfully.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+    // Batch 1: several simple operations.
+    $edit = array('batch' => 'batch_1');
+    $this->drupalPost('batch_test/simple', $edit, 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_1'), t('Batch with simple operations performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+    // Batch 2: one multistep operation.
+    $edit = array('batch' => 'batch_2');
+    $this->drupalPost('batch_test/simple', $edit, 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_2'), t('Batch with multistep operation performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_2'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+    // Batch 3: simple + multistep combined.
+    $edit = array('batch' => 'batch_3');
+    $this->drupalPost('batch_test/simple', $edit, 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_3'), t('Batch with simple and multistep operations performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_3'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+    // Batch 4: nested batch.
+    $edit = array('batch' => 'batch_4');
+    $this->drupalPost('batch_test/simple', $edit, 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_4'), t('Nested batch performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_4'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+  }
+
+  /**
+   * Test batches defined in a multistep form.
+   */
+  function testBatchFormMultistep() {
+    $this->drupalGet('batch_test/multistep');
+    $this->assertText('step 1', t('Form is displayed in step 1.'));
+
+    // First step triggers batch 1.
+    $this->drupalPost(NULL, array(), 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_1'), t('Batch for step 1 performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+    $this->assertText('step 2', t('Form is displayed in step 2.'));
+
+    // Second step triggers batch 2.
+    $this->drupalPost(NULL, array(), 'Submit');
+    $this->assertBatchMessages($this->_resultMessages('batch_2'), t('Batch for step 2 performed successfully.'));
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_2'), t('Execution order was correct.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+  }
+
+  /**
+   * Test batches defined in different submit handlers on the same form.
+   */
+  function testBatchFormMultipleBatches() {
+    // Batches 1, 2 and 3 are triggered in sequence by different submit
+    // handlers. Each submit handler modify the submitted 'value'.
+    $value = rand(0, 255);
+    $edit = array('value' => $value);
+    $this->drupalPost('batch_test/chained', $edit, 'Submit');
+    // Check that result messages are present and in the correct order.
+    $this->assertBatchMessages($this->_resultMessages('chained'), t('Batches defined in separate submit handlers performed successfully.'));
+    // The stack contains execution order of batch callbacks and submit
+    // hanlders and logging of corresponding $form_state[{values'].
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('chained', $value), t('Execution order was correct, and $form_state is correctly persisted.'));
+    $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+  }
+
+  /**
+   * Test batches defined in a programmatically submitted form.
+   *
+   * Same as above, but the form is submitted through drupal_form_execute().
+   */
+  function testBatchFormProgrammatic() {
+    // Batches 1, 2 and 3 are triggered in sequence by different submit
+    // handlers. Each submit handler modify the submitted 'value'.
+    $value = rand(0, 255);
+    $this->drupalGet('batch_test/programmatic/' . $value);
+    // Check that result messages are present and in the correct order.
+    $this->assertBatchMessages($this->_resultMessages('chained'), t('Batches defined in separate submit handlers performed successfully.'));
+    // The stack contains execution order of batch callbacks and submit
+    // hanlders and logging of corresponding $form_state[{values'].
+    $this->assertEqual(batch_test_stack(), $this->_resultStack('chained', $value), t('Execution order was correct, and $form_state is correctly persisted.'));
+    $this->assertText('Got out of a programmatic batched form.', t('Page execution continues normally.'));
+  }
+
+  /**
+   * Test that drupal_form_submit() can run within a batch operation.
+   */
+  function testDrupalFormSubmitInBatch() {
+    // Displaying the page triggers a batch that programmatically submits a
+    // form.
+    $value = rand(0, 255);
+    $this->drupalGet('batch_test/nested_programmatic/' . $value);
+    $this->assertEqual(batch_test_stack(), array('mock form submitted with value = ' . $value), t('drupal_form_submit() ran successfully within a batch operation.'));
+  }
+
+  /**
+   * Will trigger a pass if the texts were found in order in the raw content.
+   *
+   * @param $texts
+   *   Array of raw strings to look for .
+   * @param $message
+   *   Message to display.
+   * @return
+   *   TRUE on pass, FALSE on fail.
+   */
+  function assertBatchMessages($texts, $message) {
+    $pattern = '|' . implode('.*', $texts) .'|s';
+    return $this->assertPattern($pattern, $message);
+  }
+
+  /**
+   * Helper function: return expected execution stacks for the test batches.
+   */
+  function _resultStack($id, $value = 0) {
+    $stack = array();
+    switch ($id) {
+      case 'batch_1':
+        for ($i = 1; $i <= 10; $i++) {
+          $stack[] = "op 1 id $i";
+        }
+        break;
+
+      case 'batch_2':
+        for ($i = 1; $i <= 10; $i++) {
+          $stack[] = "op 2 id $i";
+        }
+        break;
+
+      case 'batch_3':
+        for ($i = 1; $i <= 5; $i++) {
+          $stack[] = "op 1 id $i";
+        }
+        for ($i = 1; $i <= 5; $i++) {
+          $stack[] = "op 2 id $i";
+        }
+        for ($i = 6; $i <= 10; $i++) {
+          $stack[] = "op 1 id $i";
+        }
+        for ($i = 6; $i <= 10; $i++) {
+          $stack[] = "op 2 id $i";
+        }
+        break;
+
+      case 'batch_4':
+        for ($i = 1; $i <= 5; $i++) {
+          $stack[] = "op 1 id $i";
+        }
+        $stack[] = 'setting up batch 2';
+        for ($i = 6; $i <= 10; $i++) {
+          $stack[] = "op 1 id $i";
+        }
+        $stack = array_merge($stack, $this->_resultStack('batch_2'));
+        break;
+
+      case 'chained':
+        $stack[] = 'submit handler 1';
+        $stack[] = 'value = ' . $value;
+        $stack = array_merge($stack, $this->_resultStack('batch_1'));
+        $stack[] = 'submit handler 2';
+        $stack[] = 'value = ' . ($value + 1);
+        $stack = array_merge($stack, $this->_resultStack('batch_2'));
+        $stack[] = 'submit handler 3';
+        $stack[] = 'value = ' . ($value + 2);
+        $stack[] = 'submit handler 4';
+        $stack[] = 'value = ' . ($value + 3);
+        $stack = array_merge($stack, $this->_resultStack('batch_3'));
+        break;
+    }
+    return $stack;
+  }
+
+  /**
+   * Helper function: return expected result messages for the test batches.
+   */
+  function _resultMessages($id) {
+    $messages = array();
+
+    switch ($id) {
+      case 'batch_0':
+        $messages[] = 'results for batch 0<br />none';
+        break;
+
+      case 'batch_1':
+        $messages[] = 'results for batch 1<br />op 1: processed 10 elements';
+        break;
+
+      case 'batch_2':
+        $messages[] = 'results for batch 2<br />op 2: processed 10 elements';
+        break;
+
+      case 'batch_3':
+        $messages[] = 'results for batch 3<br />op 1: processed 10 elements<br />op 2: processed 10 elements';
+        break;
+
+      case 'batch_4':
+        $messages[] = 'results for batch 4<br />op 1: processed 10 elements';
+        $messages = array_merge($messages, $this->_resultMessages('batch_2'));
+        break;
+
+      case 'chained':
+        $messages = array_merge($messages, $this->_resultMessages('batch_1'));
+        $messages = array_merge($messages, $this->_resultMessages('batch_2'));
+        $messages = array_merge($messages, $this->_resultMessages('batch_3'));
+        break;
+    }
+    return $messages;
+  }
+}
+
+/**
+ * Tests for the Batch API Progress page.
+ */
+class BatchPageTestCase extends DrupalWebTestCase {
+  public static function getInfo() {
+    return array(
+      'name' => 'Batch progress page',
+      'description' => 'Test the content of the progress page.',
+      'group' => 'Batch API',
+    );
+  }
+
+  function setUp() {
+    parent::setUp('batch_test');
   }
 
   /**
    * Tests that the batch API progress page uses the correct theme.
    */
-  function testBatchAPIProgressPageTheme() {
+  function testBatchProgressPageTheme() {
+    // Make sure that the page which starts the batch (an administrative page)
+    // is using a different theme than would normally be used by the batch API.
+    variable_set('theme_default', 'garland');
+    variable_set('admin_theme', 'seven');
     // Visit an administrative page that runs a test batch, and check that the
     // theme that was used during batch execution (which the batch callback
     // function saved as a variable) matches the theme used on the
     // administrative page.
-    $this->drupalGet('admin/system-test/batch-theme');
-    $batch_theme_used = variable_get('system_test_batch_theme_used', 'garland');
-    $this->assertEqual($batch_theme_used, 'seven', t('A progressive batch correctly uses the theme of the page that started the batch.'));
+    $this->drupalGet('admin/batch_test/test_theme');
+    // The stack should contain the name of the the used on the progress page.
+    $this->assertEqual(batch_test_stack(), array('seven'), t('A progressive batch correctly uses the theme of the page that started the batch.'));
   }
 }
 
@@ -44,13 +290,13 @@ class BatchAPIThemeTestCase extends DrupalWebTestCase {
  * Tests the function _batch_api_percentage() to make sure that the rounding
  * works properly in all cases.
  */
-class BatchAPIPercentagesTestCase extends DrupalWebTestCase {
+class BatchPercentagesUnitTestCase extends DrupalUnitTestCase {
   protected $testCases = array();
 
   public static function getInfo() {
     return array(
-      'name' => 'Batch API percentages',
-      'description' => 'Tests the handling of percentage rounding in the Drupal batch API. This is critical to Drupal user experience.',
+      'name' => 'Batch percentages',
+      'description' => 'Unit tests of progress percentage rounding.',
       'group' => 'Batch API',
     );
   }
@@ -99,10 +345,9 @@ class BatchAPIPercentagesTestCase extends DrupalWebTestCase {
   }
 
   /**
-   * Test the _batch_api_percentage() function with the data stored in the
-   * testCases class variable.
+   * Test the _batch_api_percentage() function.
    */
-  function testBatchAPIPercentages() {
+  function testBatchPercentages() {
     require_once DRUPAL_ROOT . '/includes/batch.inc';
     foreach ($this->testCases as $expected_result => $arguments) {
       // PHP sometimes casts numeric strings that are array keys to integers,
diff --git a/modules/simpletest/tests/batch_test.callbacks.inc b/modules/simpletest/tests/batch_test.callbacks.inc
new file mode 100644
index 000000000000..5f24757ee667
--- /dev/null
+++ b/modules/simpletest/tests/batch_test.callbacks.inc
@@ -0,0 +1,118 @@
+<?php
+
+// $Id$
+
+/**
+ * @file
+ * Batch callbacks for the Batch API tests.
+ */
+
+/**
+ * Simple batch operation.
+ */
+function _batch_test_callback_1($id, $sleep, &$context) {
+  // No-op, but ensure the batch take a couple iterations.
+  usleep($sleep);
+  // Track execution, and store some result for post-processing in the
+  // 'finished' callback.
+  batch_test_stack("op 1 id $id");
+  $context['results'][1][] = $id;
+}
+
+/**
+ * Multistep batch operation.
+ */
+function _batch_test_callback_2($start, $total, $sleep, &$context) {
+  // Initialize context with progress information.
+  if (!isset($context['sandbox']['current'])) {
+    $context['sandbox']['current'] = $start;
+    $context['sandbox']['count'] = 0;
+  }
+
+  // Process by groups of 5 (arbitrary value).
+  $limit = 5;
+  for ($i = 0; $i < $limit && $context['sandbox']['count'] < $total; $i++) {
+    // No-op, but ensure the batch take a couple iterations.
+    usleep($sleep);
+    // Track execution, and store some result for post-processing in the
+    // 'finished' callback.
+    $id = $context['sandbox']['current'] + $i;
+    batch_test_stack("op 2 id $id");
+    $context['results'][2][] = $id;
+
+    // Update progress information.
+    $context['sandbox']['count']++;
+  }
+  $context['sandbox']['current'] += $i;
+
+  // Inform batch engine about progress.
+  if ($context['sandbox']['count'] != $total) {
+    $context['finished'] = $context['sandbox']['count'] / $total;
+  }
+}
+
+/**
+ * Batch operation setting up its own batch.
+ */
+function _batch_test_nested_batch_callback() {
+  batch_test_stack('setting up batch 2');
+  batch_set(_batch_test_batch_2());
+}
+
+/**
+ * Common 'finished' callbacks for batches 1 to 4.
+ */
+function _batch_test_finished_helper($batch_id, $success, $results, $operations) {
+  $messages = array("results for batch $batch_id");
+  if ($results) {
+    foreach ($results as $op => $op_results) {
+      $messages[] = 'op '. $op . ': processed ' . count($op_results) . ' elements';
+    }
+  }
+  else {
+    $messages[] = 'none';
+  }
+
+  if (!$success) {
+    // A fatal error occurred during the processing.
+    $error_operation = reset($operations);
+    $messages[] = t('An error occurred while processing @op with arguments:<br/>@args', array('@op' => $error_operation[0], '@args' => print_r($error_operation[1], TRUE)));
+  }
+
+  drupal_set_message(implode('<br />', $messages));
+}
+
+/**
+ * 'finished' callback for batch 0.
+ */
+function _batch_test_finished_0($success, $results, $operations) {
+  _batch_test_finished_helper(0, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 1.
+ */
+function _batch_test_finished_1($success, $results, $operations) {
+  _batch_test_finished_helper(1, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 2.
+ */
+function _batch_test_finished_2($success, $results, $operations) {
+  _batch_test_finished_helper(2, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 3.
+ */
+function _batch_test_finished_3($success, $results, $operations) {
+  _batch_test_finished_helper(3, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 4.
+ */
+function _batch_test_finished_4($success, $results, $operations) {
+  _batch_test_finished_helper(4, $success, $results, $operations);
+}
diff --git a/modules/simpletest/tests/batch_test.info b/modules/simpletest/tests/batch_test.info
new file mode 100644
index 000000000000..432c17ff71d3
--- /dev/null
+++ b/modules/simpletest/tests/batch_test.info
@@ -0,0 +1,9 @@
+; $Id$
+name = "Batch API test"
+description = "Support module for Batch API tests."
+package = Testing
+version = VERSION
+core = 7.x
+files[] = batch_test.module
+files[] = batch_test.callbacks.inc
+hidden = TRUE
diff --git a/modules/simpletest/tests/batch_test.module b/modules/simpletest/tests/batch_test.module
new file mode 100644
index 000000000000..03938aaa8288
--- /dev/null
+++ b/modules/simpletest/tests/batch_test.module
@@ -0,0 +1,475 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Helper module for the Batch API tests.
+ */
+
+/**
+ * Implement hook_menu().
+ */
+function batch_test_menu() {
+  $items = array();
+
+  $items['batch_test'] = array(
+    'title' => 'Batch test',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('batch_test_simple_form'),
+    'access callback' => TRUE,
+  );
+  // Simple form: one submit handler, setting a batch.
+  $items['batch_test/simple'] = array(
+    'title' => 'Simple',
+    'type' => MENU_DEFAULT_LOCAL_TASK,
+    'weight' => 0,
+  );
+  // Multistep form: two steps, each setting a batch.
+  $items['batch_test/multistep'] = array(
+    'title' => 'Multistep',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('batch_test_multistep_form'),
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 1,
+  );
+  // Chained form: four submit handlers, several of which set a batch.
+  $items['batch_test/chained'] = array(
+    'title' => 'Chained',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('batch_test_chained_form'),
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 2,
+  );
+  // Programmatic form: the page submits the 'Chained' form through
+  // drupal_form_submit().
+  $items['batch_test/programmatic'] = array(
+    'title' => 'Programmatic',
+    'page callback' => 'batch_test_programmatic',
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 3,
+  );
+  // No form: fire a batch simply by accessing a page.
+  $items['batch_test/no_form'] = array(
+    'title' => 'Simple page',
+    'page callback' => 'batch_test_no_form',
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 4,
+  );
+  // Tests programmatic form submission within a batch operation.
+  $items['batch_test/nested_programmatic'] = array(
+    'title' => 'Nested programmatic',
+    'page callback' => 'batch_test_nested_drupal_form_submit',
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 5,
+  );
+  // Landing page to test redirects.
+  $items['batch_test/redirect'] = array(
+    'title' => 'Redirect',
+    'page callback' => 'batch_test_redirect_page',
+    'access callback' => TRUE,
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 6,
+  );
+  //
+  // This item lives under 'admin' so that the page uses the admin theme.
+  $items['admin/batch_test/test_theme'] = array(
+    'page callback' => 'batch_test_theme_batch',
+    'access callback' => TRUE,
+    'type' => MENU_CALLBACK,
+  );
+
+  return $items;
+}
+
+/**
+ * Simple form.
+ */
+function batch_test_simple_form() {
+  $form['batch'] = array(
+    '#type' => 'select',
+    '#title' => 'Choose batch',
+    '#options' => array(
+      'batch_0' => 'batch 0',
+      'batch_1' => 'batch 1',
+      'batch_2' => 'batch 2',
+      'batch_3' => 'batch 3',
+      'batch_4' => 'batch 4',
+    ),
+  );
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => 'Submit',
+  );
+
+  return $form;
+}
+
+/**
+ * Submit handler for the simple form.
+ */
+function batch_test_simple_form_submit($form, &$form_state) {
+  batch_test_stack(NULL, TRUE);
+
+  $function = '_batch_test_' . $form_state['values']['batch'];
+  batch_set($function());
+
+  $form_state['redirect'] = 'batch_test/redirect';
+}
+
+
+/**
+ * Multistep form.
+ */
+function batch_test_multistep_form($form, &$form_state) {
+  if (empty($form_state['storage']['step'])) {
+    $form_state['storage']['step'] = 1;
+  }
+
+  $form['step_display'] = array(
+    '#markup' => 'step ' . $form_state['storage']['step'] . '<br/>',
+  );
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => 'Submit',
+  );
+
+  return $form;
+}
+
+/**
+ * Submit handler for the multistep form.
+ */
+function batch_test_multistep_form_submit($form, &$form_state) {
+  batch_test_stack(NULL, TRUE);
+
+  switch ($form_state['storage']['step']) {
+    case 1:
+      batch_set(_batch_test_batch_1());
+      break;
+    case 2:
+      batch_set(_batch_test_batch_2());
+      break;
+  }
+
+  if ($form_state['storage']['step'] < 2) {
+    $form_state['storage']['step']++;
+    $form_state['rebuild'] = TRUE;
+  }
+
+  // This will only be effective on the last step.
+  $form_state['redirect'] = 'batch_test/redirect';
+}
+
+/**
+ * Form with chained submit callbacks.
+ */
+function batch_test_chained_form() {
+  // This value is used to test that $form_state persists through batched
+  // submit handlers.
+  $form['value'] = array(
+    '#type' => 'textfield',
+    '#title' => 'Value',
+    '#default_value' => 1,
+  );
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => 'Submit',
+  );
+  $form['#submit'] = array(
+    'batch_test_chained_form_submit_1',
+    'batch_test_chained_form_submit_2',
+    'batch_test_chained_form_submit_3',
+    'batch_test_chained_form_submit_4',
+  );
+
+  return $form;
+}
+
+/**
+ * Submit handler #1 for the chained form.
+ */
+function batch_test_chained_form_submit_1($form, &$form_state) {
+  batch_test_stack(NULL, TRUE);
+
+  batch_test_stack('submit handler 1');
+  batch_test_stack('value = ' . $form_state['values']['value']);
+
+  $form_state['values']['value']++;
+  batch_set(_batch_test_batch_1());
+
+  // This redirect should not be taken into account.
+  $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #2 for the chained form.
+ */
+function batch_test_chained_form_submit_2($form, &$form_state) {
+  batch_test_stack('submit handler 2');
+  batch_test_stack('value = ' . $form_state['values']['value']);
+
+  $form_state['values']['value']++;
+  batch_set(_batch_test_batch_2());
+
+  // This redirect should not be taken into account.
+  $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #3 for the chained form.
+ */
+function batch_test_chained_form_submit_3($form, &$form_state) {
+  batch_test_stack('submit handler 3');
+  batch_test_stack('value = ' . $form_state['values']['value']);
+
+  $form_state['values']['value']++;
+
+  // This redirect should not be taken into account.
+  $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #4 for the chained form.
+ */
+function batch_test_chained_form_submit_4($form, &$form_state) {
+  batch_test_stack('submit handler 4');
+  batch_test_stack('value = ' . $form_state['values']['value']);
+
+  $form_state['values']['value']++;
+  batch_set(_batch_test_batch_3());
+
+  // This is the redirect that should prevail.
+  $form_state['redirect'] = 'batch_test/redirect';
+}
+
+/**
+ * Menu callback: programmatically submits the 'Chained' form.
+ */
+function batch_test_programmatic($value = 1) {
+  $form_state = array(
+    'values' => array('value' => $value)
+  );
+  drupal_form_submit('batch_test_chained_form', $form_state);
+  return 'Got out of a programmatic batched form.';
+}
+
+/**
+ * Menu callback: programmatically submits a form within a batch.
+ */
+function batch_test_nested_drupal_form_submit($value = 1) {
+  // Set the batch and process it.
+  $batch['operations'] = array(
+    array('_batch_test_nested_drupal_form_submit_callback', array($value)),
+  );
+  batch_set($batch);
+  batch_process('batch_test/redirect');
+}
+
+/**
+ * Batch operation: submits form_test_mock_form using drupal_form_submit().
+ */
+function _batch_test_nested_drupal_form_submit_callback($value) {
+  $state['values']['test_value'] = $value;
+  drupal_form_submit('batch_test_mock_form', $state);
+}
+
+/**
+ * A simple form with a textfield and submit button.
+ */
+function batch_test_mock_form($form, $form_state) {
+  $form['test_value'] = array(
+    '#type' => 'textfield',
+  );
+  $form['submit'] = array(
+    '#type' => 'submit',
+    '#value' => t('Submit'),
+  );
+
+  return $form;
+}
+
+/**
+ * Submit handler for the batch_test_mock form.
+ */
+function batch_test_mock_form_submit($form, &$form_state) {
+  batch_test_stack('mock form submitted with value = ' . $form_state['values']['test_value']);
+}
+
+/**
+ * Menu callback: fire a batch process without a form submission.
+ */
+function batch_test_no_form() {
+  batch_test_stack(NULL, TRUE);
+
+  batch_set(_batch_test_batch_1());
+  batch_process('batch_test/redirect');
+}
+
+/**
+ * Menu callback: successful redirection.
+ */
+function batch_test_redirect_page() {
+  return 'Redirection successful.';
+}
+
+/**
+ * Batch 0: no operation.
+ */
+function _batch_test_batch_0() {
+  $batch = array(
+    'operations' => array(),
+    'finished' => '_batch_test_finished_0',
+    'file' => drupal_get_path('module', 'batch_test'). '/batch_test.callbacks.inc',
+  );
+  return $batch;
+}
+
+/**
+ * Batch 1: repeats a simple operation.
+ *
+ * Operations: op 1 from 1 to 10.
+ */
+function _batch_test_batch_1() {
+  // Ensure the batch takes at least two iterations.
+  $total = 10;
+  $sleep = (1000000 / $total) * 2;
+
+  $operations = array();
+  for ($i = 1; $i <= $total; $i++) {
+    $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+  }
+  $batch = array(
+    'operations' => $operations,
+    'finished' => '_batch_test_finished_1',
+    'file' => drupal_get_path('module', 'batch_test'). '/batch_test.callbacks.inc',
+  );
+  return $batch;
+}
+
+/**
+ * Batch 2: single multistep operation.
+ *
+ * Operations: op 2 from 1 to 10.
+ */
+function _batch_test_batch_2() {
+  // Ensure the batch takes at least two iterations.
+  $total = 10;
+  $sleep = (1000000 / $total) * 2;
+
+  $operations = array(
+    array('_batch_test_callback_2', array(1, $total, $sleep)),
+  );
+  $batch = array(
+    'operations' => $operations,
+    'finished' => '_batch_test_finished_2',
+    'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+  );
+  return $batch;
+}
+
+/**
+ * Batch 3: both single and multistep operations.
+ *
+ * Operations:
+ * - op 1 from 1 to 5,
+ * - op 2 from 1 to 5,
+ * - op 1 from 6 to 10,
+ * - op 2 from 6 to 10.
+ */
+function _batch_test_batch_3() {
+  // Ensure the batch takes at least two iterations.
+  $total = 10;
+  $sleep = (1000000 / $total) * 2;
+
+  $operations = array();
+  for ($i = 1; $i <= round($total / 2); $i++) {
+    $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+  }
+  $operations[] = array('_batch_test_callback_2', array(1, $total / 2, $sleep));
+  for ($i = round($total / 2) + 1; $i <= $total; $i++) {
+    $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+  }
+  $operations[] = array('_batch_test_callback_2', array(6, $total / 2, $sleep));
+  $batch = array(
+    'operations' => $operations,
+    'finished' => '_batch_test_finished_3',
+    'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+  );
+  return $batch;
+}
+
+/**
+ * Batch 4: batch within a batch.
+ *
+ * Operations:
+ * - op 1 from 1 to 5,
+ * - set batch 2 (op 2 from 1 to 10, should run at the end)
+ * - op 1 from 6 to 10,
+ */
+function _batch_test_batch_4() {
+  // Ensure the batch takes at least two iterations.
+  $total = 10;
+  $sleep = (1000000 / $total) * 2;
+
+  $operations = array();
+  for ($i = 1; $i <= round($total / 2); $i++) {
+    $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+  }
+  $operations[] = array('_batch_test_nested_batch_callback', array());
+  for ($i = round($total / 2) + 1; $i <= $total; $i++) {
+    $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+  }
+  $batch = array(
+    'operations' => $operations,
+    'finished' => '_batch_test_finished_4',
+    'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+  );
+  return $batch;
+}
+
+/**
+ * Menu callback: run a batch for testing theme used on the progress page.
+ */
+function batch_test_theme_batch() {
+  batch_test_stack(NULL, TRUE);
+  $batch = array(
+    'operations' => array(
+      array('_batch_test_theme_callback', array()),
+    ),
+  );
+  batch_set($batch);
+  batch_process('batch_test/redirect');
+}
+
+/**
+ * Batch callback function for testing the theme used on the progress page.
+ */
+function _batch_test_theme_callback() {
+  // Because drupalGet() steps through the full progressive batch before
+  // returning control to the test function, we cannot test that the correct
+  // theme is being used on the batch processing page by viewing that page
+  // directly. Instead, we save the theme being used in a variable here, so
+  // that it can be loaded and inspected in the thread running the test.
+  global $theme;
+  batch_test_stack($theme);
+}
+
+/**
+ * Helper function: store or retrieve traced execution data.
+ */
+function batch_test_stack($data = NULL, $reset = FALSE) {
+  if ($reset) {
+    variable_del('batch_test_stack');
+  }
+  if (is_null($data)) {
+    return variable_get('batch_test_stack', array());
+  }
+  $stack = variable_get('batch_test_stack', array());
+  $stack[] = $data;
+  variable_set('batch_test_stack', $stack);
+}
diff --git a/modules/simpletest/tests/form.test b/modules/simpletest/tests/form.test
index 10839177757b..b7dec7b6a95a 100644
--- a/modules/simpletest/tests/form.test
+++ b/modules/simpletest/tests/form.test
@@ -511,45 +511,6 @@ class FormsElementsTableSelectFunctionalTest extends DrupalWebTestCase {
 
 }
 
-/**
- * Test using drupal_form_submit in a batch.
- */
-class FormAPITestCase extends DrupalWebTestCase {
-
-  public static function getInfo() {
-    return array(
-      'name' => 'Drupal Execute and Batch API',
-      'description' => 'Tests the compatibility of drupal_form_submit and the Batch API',
-      'group' => 'Form API',
-    );
-  }
-
-  /**
-   * Check that we can run drupal_form_submit during a batch.
-   */
-  function testDrupalFormSubmitInBatch() {
-
-    // Our test is going to modify the following variable.
-    variable_set('form_test_mock_submit', 'initial_state');
-
-    // This is a page that sets a batch, which calls drupal_form_submit, which
-    // modifies the variable we set up above.
-    $this->drupalGet('form_test/drupal_form_submit_batch_api');
-
-    // If the drupal_form_submit call executed correctly our test variable will be
-    // set to 'form_submitted'.
-    $this->assertEqual('form_submitted', variable_get('form_test_mock_submit', 'initial_state'), t('Check drupal_form_submit called submit handlers when running in a batch'));
-
-    // Clean our variable up.
-    variable_del('form_test_mock_submit');
-  }
-
-  function setUp() {
-    parent::setUp('form_test');
-  }
-
-}
-
 /**
  * Test the form storage on a multistep form.
  *
diff --git a/modules/simpletest/tests/form_test.module b/modules/simpletest/tests/form_test.module
index c8f565938361..54b6299f8d4b 100644
--- a/modules/simpletest/tests/form_test.module
+++ b/modules/simpletest/tests/form_test.module
@@ -54,13 +54,6 @@ function form_test_menu() {
     'type' => MENU_CALLBACK,
   );
 
-  $items['form_test/drupal_form_submit_batch_api'] = array(
-    'title' => 'BatchAPI Drupal_form_submit tests',
-    'page callback' => 'form_test_drupal_form_submit_batch_api',
-    'access arguments' => array('access content'),
-    'type' => MENU_CALLBACK,
-  );
-
   $items['form_test/form-storage'] = array(
     'title' => 'Form storage test',
     'page callback' => 'drupal_get_form',
@@ -375,67 +368,6 @@ function _form_test_tableselect_js_select_form($form, $form_state, $action) {
   return _form_test_tableselect_form_builder($form, $form_state, $options);
 }
 
-/**
- * Page callback for the batch/drupal_form_submit interaction test.
- *
- * When called without any arguments we set up a batch that calls
- * form_test_batch_callback. That function will submit a form using
- * drupal_form_submit using the values specified in this function.
- *
- * The form's field test_value begins at 'initial_value', and is changed
- * to 'form_submitted' when the form is submitted successfully. On
- * completion this function is passed 'done' to complete the process.
- */
-function form_test_drupal_form_submit_batch_api($arg = '') {
-  // If we're at the end of the batch process, return.
-  if ($arg == 'done') {
-    return t('Done');
-  }
-
-  // Otherwise set up the batch.
-  $batch['operations'] = array(
-    array('form_test_batch_callback', array('form_submitted')),
-  );
-
-  // Set the batch and process it.
-  batch_set($batch);
-  batch_process('form_test/drupal_form_submit_batch_api/done');
-}
-
-/**
- * Submits form_test_mock_form using drupal_form_submit using the given $value.
- */
-function form_test_batch_callback($value) {
-  $state['values']['test_value'] = $value;
-  drupal_form_submit('form_test_mock_form', $state);
-}
-
-/**
- * A simple form with a textfield and submit button.
- */
-function form_test_mock_form($form, $form_state) {
-  $form['test_value'] = array(
-    '#type' => 'textfield',
-    '#default_value' => 'initial_state',
-  );
-
-  $form['submit'] = array(
-    '#type' => 'submit',
-    '#value' => t('Submit'),
-  );
-
-  return $form;
-}
-
-/**
- * Form submission callback.
- *
- * Updates the variable 'form_test_mock_submit' to the submitted form value.
- */
-function form_test_mock_form_submit($form, &$form_state) {
-  variable_set('form_test_mock_submit', $form_state['values']['test_value']);
-}
-
 /**
  * A multistep form for testing the form storage.
  *
diff --git a/modules/simpletest/tests/system_test.module b/modules/simpletest/tests/system_test.module
index 5c57ee20d9bc..b37019f8c6a7 100644
--- a/modules/simpletest/tests/system_test.module
+++ b/modules/simpletest/tests/system_test.module
@@ -5,11 +5,6 @@
  * Implements hook_menu().
  */
 function system_test_menu() {
-  $items['admin/system-test/batch-theme'] = array(
-    'page callback' => 'system_test_batch_theme',
-    'access callback' => TRUE,
-    'type' => MENU_CALLBACK,
-  );
   $items['system-test/sleep/%'] = array(
     'page callback' => 'system_test_sleep',
     'page arguments' => array(2),
@@ -102,34 +97,6 @@ function system_test_menu() {
   return $items;
 }
 
-/**
- * Menu callback; start a new batch for testing the batch progress page theme.
- */
-function system_test_batch_theme() {
-  $batch = array(
-    'operations' => array(
-      array('system_test_batch_theme_callback', array()),
-    ),
-  );
-  batch_set($batch);
-  // Force the batch to redirect to some page other than this one (to avoid an
-  // infinite loop).
-  batch_process('node');
-}
-
-/**
- * Batch callback function for testing the theme used by a batch.
- */
-function system_test_batch_theme_callback() {
-  // Because drupalGet() steps through the full progressive batch before
-  // returning control to the test function, we cannot test that the correct
-  // theme is being used on the batch processing page by viewing that page
-  // directly. Instead, we save the theme being used in a variable here, so
-  // that it can be loaded and inspected in the thread running the test.
-  global $theme;
-  variable_set('system_test_batch_theme_used', $theme);
-}
-
 function system_test_sleep($seconds) {
   sleep($seconds);
 }
diff --git a/modules/system/system.install b/modules/system/system.install
index add4c37c4ea7..519cbabda5b7 100644
--- a/modules/system/system.install
+++ b/modules/system/system.install
@@ -496,7 +496,9 @@ function system_schema() {
     'fields' => array(
       'bid' => array(
         'description' => 'Primary Key: Unique batch ID.',
-        'type' => 'serial',
+        // This is not a serial column, to allow both progressive and
+        // non-progressive batches. See batch_process().
+        'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
       ),
@@ -2257,50 +2259,7 @@ function system_update_7021() {
  * Add the queue tables.
  */
 function system_update_7022() {
-  $schema['queue'] = array(
-    'description' => 'Stores items in queues.',
-    'fields' => array(
-      'item_id' => array(
-        'type' => 'serial',
-        'unsigned' => TRUE,
-        'not null' => TRUE,
-        'description' => 'Primary Key: Unique item ID.',
-      ),
-      'name' => array(
-        'type' => 'varchar',
-        'length' => 255,
-        'not null' => TRUE,
-        'default' => '',
-        'description' => 'The queue name.',
-      ),
-      'data' => array(
-        'type' => 'text',
-        'not null' => FALSE,
-        'size' => 'big',
-        'serialize' => TRUE,
-        'description' => 'The arbitrary data for the item.',
-      ),
-      'expire' => array(
-        'type' => 'int',
-        'not null' => TRUE,
-        'default' => 0,
-        'description' => 'Timestamp when the claim lease expires on the item.',
-      ),
-      'created' => array(
-        'type' => 'int',
-        'not null' => TRUE,
-        'default' => 0,
-        'description' => 'Timestamp when the item was created.',
-      ),
-    ),
-    'primary key' => array('item_id'),
-    'indexes' => array(
-      'name_created' => array('name', 'created'),
-      'expire' => array('expire'),
-    ),
-  );
-
-  db_create_table('queue', $schema['queue']);
+  // Moved to update_fix_d7_requirements().
 }
 
 /**
@@ -2727,6 +2686,13 @@ function system_update_7049() {
   }
 }
 
+/**
+ * Change {batch}.id column from serial to regular int.
+ */
+function system_update_7050() {
+  db_change_field('batch', 'bid', 'bid', array('description' => 'Primary Key: Unique batch ID.', 'type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE));
+}
+
 /**
  * @} End of "defgroup updates-6.x-to-7.x"
  * The next series of updates should start at 8000.
diff --git a/modules/system/system.module b/modules/system/system.module
index e0da1720bc0b..6247fcb2f109 100644
--- a/modules/system/system.module
+++ b/modules/system/system.module
@@ -2695,10 +2695,6 @@ function system_cron() {
   db_delete('flood')
     ->condition('expiration', REQUEST_TIME, '<')
     ->execute();
-  // Cleanup the batch table.
-  db_delete('batch')
-    ->condition('timestamp', REQUEST_TIME - 864000, '<')
-    ->execute();
 
   // Remove temporary files that are older than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
   // Use separate placeholders for the status to avoid a bug in some versions
@@ -2722,6 +2718,15 @@ function system_cron() {
     cache_clear_all(NULL, $table);
   }
 
+  // Cleanup the batch table and the queue for failed batches.
+  db_delete('batch')
+    ->condition('timestamp', REQUEST_TIME - 864000, '<')
+    ->execute();
+  db_delete('queue')
+    ->condition('created', REQUEST_TIME - 864000, '<')
+    ->condition('name', 'drupal_batch:%', 'LIKE')
+    ->execute();
+
   // Reset expired items in the default queue implementation table. If that's
   // not used, this will simply be a no-op.
   db_update('queue')
diff --git a/modules/system/system.queue.inc b/modules/system/system.queue.inc
index 5174a0a6521a..bac1ff26e6e4 100644
--- a/modules/system/system.queue.inc
+++ b/modules/system/system.queue.inc
@@ -253,6 +253,79 @@ public function deleteQueue() {
   }
 }
 
+/**
+ * Static queue implementation.
+ *
+ * This allows "undelayed" variants of processes relying on the Queue
+ * interface. The queue data resides in memory. It should only be used for
+ * items that will be queued and dequeued within a given page request.
+ */
+class MemoryQueue implements DrupalQueueInterface {
+  /**
+   * The queue data.
+   *
+   * @var array
+   */
+  protected $queue;
+
+  /**
+   * Counter for item ids.
+   *
+   * @var int
+   */
+  protected $id_sequence;
+
+  public function __construct($name) {
+    $this->queue = array();
+    $this->id_sequence = 0;
+  }
+
+  public function createItem($data) {
+    $item = new stdClass();
+    $item->item_id = $this->id_sequence++;
+    $item->data = $data;
+    $item->created = time();
+    $item->expire = 0;
+    $this->queue[$item->item_id] = $item;
+  }
+
+  public function numberOfItems() {
+    return count($this->queue);
+  }
+
+  public function claimItem($lease_time = 30) {
+    foreach ($this->queue as $key => $item) {
+      if ($item->expire == 0) {
+        $item->expire = time() + $lease_time;
+        $this->queue[$key] = $item;
+        return $item;
+      }
+    }
+    return FALSE;
+  }
+
+  public function deleteItem($item) {
+    unset($this->queue[$item->item_id]);
+  }
+
+  public function releaseItem($item) {
+    if (isset($this->queue[$item->item_id]) && $this->queue[$item->item_id]->expire != 0) {
+      $this->queue[$item->item_id]->expire = 0;
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  public function createQueue() {
+    // Nothing needed here.
+  }
+
+  public function deleteQueue() {
+    $this->queue = array();
+    $this->id_sequence = 0;
+  }
+}
+
 /**
  * @} End of "defgroup queue".
  */
-- 
GitLab