Drupal - How to handle non-fatal errors during a batch operation?

Preamble:
The answer given by burnsjermey did help me find the solution. He deserves kudos for pointing me to the correct documentation and this project on github. Both helped a lot, so I've accepted his answer.

However, his answer contains a lot of stuff that has nothing to do with the question. Also, his code examples have bugs that stops them from working. Therefore, I also want to add a self-answer containing only code snippets that are relevant to the question asked.


Self-answer:
First, as pointed out in the API documentation, non-fatal error management should be handled using 'results'. Hence, when we encounter a non-fatal error, we add an error message to the results array:

function mymodule_process_one($file, &$context) {
  if (!is_readable ($file)) {
    $context['results']['error'][] = t('@file is not readable.', array('@file' => $file));
  }
  else {
    $context['results'][] = t('@file processed.', array('@file' => $file));
  }
}

Then, in the finished-callback, we put code that looks for these errors, and prints them out if there are any.

function mymodule_finished($success, $results, $operations) {
  if ($success) {
    if (isset($results['error'])) {
      drupal_set_message(t('There were !count non-fatal error(s):', array(
       '!count' => count($results['error']),
      )), 'error');
      foreach ($results['error'] as $error) {
         drupal_set_message($error, 'error');
      }
    }
    else {
      drupal_set_message(t('No errors.'));
    }
  } else {
    $error_operation = reset($operations);
    drupal_set_message(t('A fatal error occurred'));
  }
}

That's all folks!


PS:
If you want to learn how to handle fatal errors during batch processing, the best answer is the accepted answer to: Can a batch process be stopped.


See here for more information on this: https://api.drupal.org/api/drupal/includes%21form.inc/group/batch/7

Updated answer:

After the comments I realized what you are trying to do, so I setup the example module and came up with this code to show if one error happened but others ran. You can take a look and hopefully use this to help come up with your final answer.

<?php

/**
 * @file
 * Outlines how a module can use the Batch API.
 */

/**
 * @defgroup batch_example Example: Batch API
 * @ingroup examples
 * @{
 * Outlines how a module can use the Batch API.
 *
 * Batches allow heavy processing to be spread out over several page
 * requests, ensuring that the processing does not get interrupted
 * because of a PHP timeout, while allowing the user to receive feedback
 * on the progress of the ongoing operations. It also can prevent out of memory
 * situations.
 *
 * The @link batch_example.install .install file @endlink also shows how the
 * Batch API can be used to handle long-running hook_update_N() functions.
 *
 * Two harmless batches are defined:
 * - batch 1: Load the node with the lowest nid 100 times.
 * - batch 2: Load all nodes, 20 times and uses a progressive op, loading nodes
 *   by groups of 5.
 * @see batch
 */

/**
 * Implements hook_menu().
 */
function batch_example_menu() {
  $items = array();
  $items['examples/batch_example'] = array(
    'title' => 'Batch example',
    'description' => 'Example of Drupal batch processing',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('batch_example_simple_form'),
    'access callback' => TRUE,
  );

  return $items;
}

/**
 * Form builder function to allow choice of which batch to run.
 */
function batch_example_simple_form() {
  $form['description'] = array(
    '#type' => 'markup',
    '#markup' => t('This example is modified for files and sets error if only one file is not readable.'),
  );
  $form['batch'] = array(
    '#type' => 'select',
    '#title' => 'Choose batch',
    '#options' => array(
      'batch_1' => t('batch 1 - load files'),
    ),
  );
  $form['submit'] = array(
    '#type' => 'submit',
    '#value' => 'Go',
  );

  // If no nodes, prevent submission.
  // Find out if we have a node to work with. Otherwise it won't work.
  $nid = batch_example_lowest_nid();
  if (empty($nid)) {
    drupal_set_message(t("You don't currently have any nodes, and this example requires a node to work with. As a result, this form is disabled."));
    $form['submit']['#disabled'] = TRUE;
  }
  return $form;
}

/**
 * Submit handler.
 *
 * @param array $form
 *   Form API form.
 * @param array $form_state
 *   Form API form.
 */
function batch_example_simple_form_submit($form, &$form_state) {
  $function = 'batch_example_' . $form_state['values']['batch'];

  // Reset counter for debug information.
  $_SESSION['http_request_count'] = 0;

  // Execute the function named batch_example_1 or batch_example_2.
  $batch = $function();
  batch_set($batch);
}


/**
 * Batch 1 definition: Scan file directory /maintenance on test site.
 *
 */
function batch_example_batch_1() {

  $operations = array();


  $files = file_scan_directory('sites/default/files/maintenance', '/.*\.(png|jpg)$/');
  $i = 0;
  foreach ($files as $value) {
    $file_efq = new EntityFieldQuery();
    $file_efq->entityCondition('entity_type', 'file')
      ->propertyCondition('filename', $value->filename);
    $file_efq_result = $file_efq->execute();
    if (count($file_efq_result)) {
      // This will only add the first match from EFQ for this filename
      $fid = array_pop($file_efq_result['file'])->fid;
    }
    if (!empty($fid)){
       $operations[] = array(
        'batch_example_op_1',
        array(
          $fid,
          t('(Operation @operation)', array('@operation' => $i)),
        ),
      );
      $i++;
    }
  }

  $batch = array(
    'operations' => $operations,
    'finished' => 'batch_example_finished',
  );
  return $batch;
}

/**
 * Batch operation for batch 1: load a file.
 *
 * This is the function that is called on each operation in batch 1.
 */
function batch_example_op_1($fid, $operation_details, &$context) {
  // $node = node_load($nid, NULL, TRUE);
     $file = file_load($fid);
  // Store some results for post-processing in the 'finished' callback.
  // The contents of 'results' will be available as $results in the
  // 'finished' function (in this example, batch_example_finished()).
  if (is_readable($file->uri)){
    $context['results'][] = $file->fid . ' : ' . check_plain($file->filename) . ' ' . $operation_details;
  }else{
    $context['results']['error'] = $file->fid . ' : ' . check_plain($file->filename) . ' ' . $operation_details;
  }
  // Optional message displayed under the progressbar.
  $context['message'] = t('Loading file "@uri"', array('@uri' => $file->uri)) . ' ' . $operation_details;

  _batch_example_update_http_requests();
}


/**
 * Batch 'finished' callback used by both batch 1 and batch 2.
 */
function batch_example_finished($success, $results, $operations) {

  if (!$results['error']) {
    // Here we could do something meaningful with the results.
    // We just display the number of nodes we processed...
    drupal_set_message(t('@count results processed in @requests HTTP requests.', array('@count' => count($results), '@requests' => _batch_example_get_http_requests())));
    drupal_set_message(t('The final result was "%final"', array('%final' => end($results))));
  }
  else {
    // An error occurred.
    // $operations contains the operations that remained unprocessed.
    $error_operation = $results['error'];
    drupal_set_message(
      t('An error occurred while processing @operation',
        array(
          '@operation' => print_r($error_operation)
        )
      )
    );
    drupal_set_message(t('The final result was "%final"', array('%final' => end($results))));
  }
}



/**
 * Utility function to increment HTTP requests in a session variable.
 */
function _batch_example_update_http_requests() {
  $_SESSION['http_request_count']++;
}

/**
 * Utility function to count the HTTP requests in a session variable.
 *
 * @return int
 *   Number of requests.
 */
function _batch_example_get_http_requests() {
  return !empty($_SESSION['http_request_count']) ? $_SESSION['http_request_count'] : 0;
}
/**
 * @} End of "defgroup batch_example".
 */
?>

You can see where I scan a public directory on my site, then I go in and set one of those files to not be readable by Drupal and I get my error message. Looking at the pages I included earlier that is exactly what you are supposed to do and the $operations variable would be anything that fails with a Fatal error. Hope that helps. If you need any explanations just let me know, and sorry about leaving examples in there, I will try to clean it up later.

The main part of the code from above that answers OP's question is below with explanation:

  if (is_readable($file->uri)){
    $context['results'][] = $file->fid . ' : ' . check_plain($file->filename) . ' ' . $operation_details;
  }else{
    $context['results']['error'] = $file->fid . ' : ' . check_plain($file->filename) . ' ' . $operation_details;
  }

In the batch documentation it states:

// The 'success' parameter means no fatal PHP errors were detected. All
// other error management should be handled using 'results'.

So you would have to change your finished callback to include the error management also.

// I don't recommend using this, I just used it as a test. You should use $success and your other error management handling together here.
if (!$results['error']) {
    // Here we could do something meaningful with the results.
    // We just display the number of nodes we processed...
    drupal_set_message(t('@count results processed in @requests HTTP requests.', array('@count' => count($results), '@requests' => _batch_example_get_http_requests())));
    drupal_set_message(t('The final result was "%final"', array('%final' => end($results))));
  }
  else {
    // An error occurred.
    // $operations contains the operations that remained unprocessed.
    $error_operation = $results['error'];
    drupal_set_message(
      t('An error occurred while processing @operation',
        array(
          '@operation' => print_r($error_operation)
        )
      )
    );
    drupal_set_message(t('The final result was "%final"', array('%final' => end($results))));
  }

While looking at some more of the doc pages I found a link to a github module that has another setup of error handling view $results array and how they use this. This may help to see what can actually be done here and other ways it can be setup. Here is the page for that module CSV Import. This is the documentation page that was linked to the module code that has the example there, you will notice that $context['results']['failed_rows'][] is what they use in their error handling.

Old parts of answer, left b/c some of the information is relevant or could be to future observers.

This is the way the example modules show to print errors and number of items that completed at the end with the callback function. It says the $operations variable shows the number of operations that did not run.

function batch_example_finished($success, $results, $operations) {
if ($success) {
  // Here we could do something meaningful with the results.
  // We just display the number of nodes we processed...
  drupal_set_message(t('@count results processed in @requests HTTP requests.', array('@count' => count($results), '@requests' => _batch_example_get_http_requests())));
  drupal_set_message(t('The final result was "%final"', array('%final' => end($results))));
}
else {
  // An error occurred.
  // $operations contains the operations that remained unprocessed.
  $error_operation = reset($operations);
  drupal_set_message(t('An error occurred while processing @operation with arguments : @args', array(
   '@operation' => $error_operation[0],
   '@args' =>  print_r($error_operation[0], TRUE),
    )));
  }
}

You can find more about this in the example modules: https://api.drupal.org/api/examples/batch_example%21batch_example.module/function/batch_example_finished/7

If you want to make sure that the success works (may not work with some non-core modules enabled) you can do a couple of different things to throw an error.

Explicitly set an error in the json callbacks

<?php
   function some_batch_operation(&$context) {
     // do stuff
     // if something goes wrong
     $result = array('status' => FALSE, 'data' => $message);
     die(json_encode($result));
   }
?>

Throw an exception.

<?php
   throw new Exception(t('My error message.')); 
 ?>