Continue with the previous part, in which we created a task queue that's able to execute asynchronous tasks one by one if no error occurs. Now we'll do more with it and make it really usable.

Handle Errors

In a task, we should be able to either synchronously throw an error or callback with an error as the first argument to tell something is not right.

Handle Synchronous Errors

A synchronous error means an error thrown:

  1. After the function is called.
  2. Before the function returns.

And obviously we'll need try...catch statement here to handle:

try {
    task(function () {
        next();
    });
} catch (error) {
    // ...
}

Handle Asynchronous Errors

An asynchronous error means an error occurs after the function returns.

To know whether there is an asynchronous error, we'll rely on the first argument of the callback passed into task. Though this is assuming that any asynchronous error will be called back as the first argument.

task(function (error) {
    if (error) {
        // ...
    } else {
        next();
    }
});

Callback with Error We Caught

We have defined the interface of runTasks function, but it could not tell whether an error happened during the execution of tasks.

Our tasks will call back with the error (if any) as the first argument. If you are familiar with Node.js, you might find it sort of a convention. So we'll apply this form here, too.

runTasks(tasks, function (error) {
    if (error) {
        console.log('An error occurs.');
        console.log(error);
    } else {
        console.log('Done without error.');
    }
});

Then we may implement some error handling logic:

function runTasks(tasks, callback) {
    next();
    
    function next() {
        if (tasks.length === 0) {
            callback();
            return;
        }
    
        var task = tasks.shift();
        
        try {
            task(function (error) {
                if (error) {
                    // Callback with asynchronous error.
                    callback(error);
                } else {
                    // Current task completes with no error,
                    // go on with the next one.
                    next();
                }
            });
        } catch (error) {
            // Callback with synchronous error.
            callback(error);
        }
    }
}

Some Details before We Moving On

One goal of this part is to make the task queue practically usable. Which means we have to avoid some issues in real life, even if they are so rare.

Callback Passed to the Task Being Called More than Once

Usually for this case, we choose to ignore any call other than the first one. To achieve this, another variable that indicates the callback status of current task could be added.

var settled = false;

task(function (error) {
    // If this callback has already been called, skip this call.
    if (settled) {
        return;
    }
    
    // Mark the current task as settled.
    settled = true;
    
    if (error) {
        callback(error);
    } else {
        next();
    }
});

Callback Passed to the Task Being Called after the Task Synchronously Throws an Error

Think it twice and we found a variant of the previous issue, so we'll also need to mark the task as settled in catch block.

Callback Passed to the Task Being Called before the Task Synchronously Throws an Error

Again, we also need to determine whether the task is already settled before we invoke the callback in catch block.

Do Not Release Zalgo

Consider code like this:

runTasks(tasks, function () {
    console.log('second');
});

console.log('first');

We are expecting "first" always been logged before "second". However without the control of how tasks behave, the code above can actually result in "second" before "first". For example, an empty tasks array or tasks that call callbacks synchronously.

To make things more predictable, let's make sure callback of function runTasks will be called asynchronously.

After some efforts, we get the code below:

function runTasks(tasks, callback) {
    // If this task queue ends synchronously, sync will still be `true`
    // when `invokeCallback` helper is called.
    var sync = true;
    next();
    sync = false;
    
    function invokeCallback(error) {
        if (sync) {
            // We can also use `setImmediate` if available.
            setTimeout(function () {
                callback(error);
            }, 0);
        } else {
            callback(error);
        }
    }
    
    function next() {
        if (tasks.length === 0) {
            invokeCallback();
            return;
        }
    
        var task = tasks.shift();
        var settled = false;
        
        try {
            task(function (error) {
                if (settled) {
                    return;
                }
                
                settled = true;
                
                if (error) {
                    invokeCallback(error);
                } else {
                    next();
                }
            });
        } catch (error) {
            if (settled) {
                return;
            }
            
            settled = true;
            invokeCallback(error);
        }
    }
}

And congratulations! Now we have a practically usable task queue!

Report Progress

Having walked through so many "troubles", progress reporting should now be a very simple feature to implement.

But before that, let's confirm the design of interface. What I want is:

  1. The number of completed tasks.
  2. The number of all tasks.

We may either use the same callback we used before, or another callback especially for progress. Here we'll go with the later choice, an onprogress handler.

runTasks(tasks, function (error) {
    console.log(error || 'Done.');
}, function (completed, total) {
    console.log('Progress ' + completed + '/' + total + '...');
});

And let's fill the implementation:

function runTasks(tasks, callback, onprogress) {
    // Avoid changes to this list.
    tasks = tasks.concat();
    
    // Counters.
    var completed = 0;
    var total = tasks.length;
    
    var sync = true;
    next();
    sync = false;
    
    function invokeCallback(error) {
        if (sync) {
            // We can also use `setImmediate` if available.
            setTimeout(function () {
                callback(error);
            }, 0);
        } else {
            callback(error);
        }
    }
    
    function next() {
        // Call `onprogress` handler with the information we want.
        onprogress(completed, total);

        if (tasks.length === 0) {
            invokeCallback();
            return;
        }
        
        var task = tasks.shift();
        var settled = false;
        
        try {
            task(function (error) {
                if (settled) {
                    return;
                }
                
                settled = true;
                
                if (error) {
                    invokeCallback(error);
                } else {
                    completed++;
                    next();
                }
            });
        } catch (error) {
            if (settled) {
                return;
            }
            
            settled = true;
            invokeCallback(error);
        }
    }
}

Summary of Part 2

In this part, we improved the usability of our task queue by properly handling errors and adding progress reporting. We didn't make it perfect at the very beginning, but as we think (and practice) again and again, we are making it better for dealing with real-life issues.

In the next part, we'll finally come to concurrency control. :)