Serialize Asynchronous Operations with ReactiveCocoa

In his Easy Asynchronous Operations in iOS with ReactiveCocoa post, John Fisher described how to use -flattenMap to chain together signals that wrap asynchronous operations. He also described a technique for serializing those chains of operations by executing the chain on a serial RACScheduler.

The serial scheduler technique works in some situations, but I’ve run into issues trying to apply it in others. In this post I’m going to talk about an alternate way of serializing chains of asynchronous operations in ReactiveCocoa using -map and -concat.

An Example App

For this example, let’s say we’re building an app that someone can use to track the number of times they sneeze. The interface will have a single button, “I Sneezed”, and a single label that displays the sneeze count. The app will integrate with an external sneeze counter HTTP API that will handle the complex math needed to total up the running sneeze count.

Because sneezes can occur in rapid succession at times, we don’t ever want the “I Sneezed” button to be disabled. It should always accept a tap and use the API to increment the total. If the tap events come in faster than the API is able to process them, the app needs to queue up the requests so as not to lose any sneezes, or create a race condition where two API requests are being processed in parallel, potentially resulting in an inaccurate result.

Unfortunately the API was not well designed and requires several requests to get a new total. First a POST to create a new calculation, then a PUT to set the current total in the calculation, then another POST to create the result of the calculation, and finally a GET to retrieve the result.

Chaining the Asynchronous Operations

We’ll use the standard ReactiveCocoa technique of using -flattenMap: to chain together the operations that need to happen in sequence:


RACSignal *incrementNumberSignal = [[[[[buttonTappedSignal
    flattenMap:^RACStream *(id x) {
        return [api createNewCalculation];
    }]
    flattenMap:^RACStream *(NSNumber *calculationID) {
        NSNumber *total = 
            [[NSUserDefaults standardUserDefaults] objectForKey:@"total"];
        return [api updateCalculation:calculationID withCurrentValue:total];
    }]
    flattenMap:^RACStream *(NSNumber *calculationID) {
        return [api createResultOfCalculation:calculationID];
    }]
    flattenMap:^RACStream *(NSNumber *resultID) {
        return [api fetchResult:resultID];
    }]
    doNext:^(NSNumber *newTotal) {
        [[NSUserDefaults standardUserDefaults] setObject:newTotal
                                                  forKey:@"total"];
    }];

I’m not going to go into detail about how the -flattenMap: works here, as that’s covered well in the post mentioned earlier.

But at a high level, an api helper object is used to make the asynchronous API calls, each of which returns a RACSignal that _yields_ a value that’s needed by a subsequent request. The sequence is kicked off every time the user taps the button and the buttonTappedSignal sends next. From there the API is used to create a new calculation, update the new calculation with the previous total pulled out of NSUserDefaults, run the calculation, fetch the new total, store it back in NSUserDefaults, and send it on so it can be displayed in the UI.

What’s the Problem?

The above code does exactly what we set out to do. It executes a series of asynchronous operations, using the output of one operation as the input to the next. And compared to the nightmare of callbacks and state that would be needed to do the same thing without ReactiveCocoa, it’s just about perfect.

Until a user starts sneezing uncontrollably and tapping the button in rapid succession. The API is slow, and with all those requests it’s quite possible that a second tap event could trigger the sequence to start while a previous tap is still being processed. And this would likely result in two requests that increment the same number, resulting in an inaccurate total.

We need a way to make sure that only one tap event is processed at a time, without losing any of the events that occur during that processing.

One at a Time

The answer is to queue up tap events, waiting until the previous tap event has been fully processed before starting to process the next one in the queue. And that can be done by using the -map: and -concat operators together.

If you return a signal from a -map:, the result is a signal of signals. There are a number of operators that operate on a signal of signals: -flatten, -switchToLatest, and concat are the three I’m most familiar with. The -concat operator will subscribe to an inner signal (in the signal of signals coming from the -map:) until it completes, and then subscribe to the next inner signal.

Put another way, it executes the signals one at a time, waiting for one to complete before starting the next one. Exactly what we need.

In the following code snippet, I moved all of the code from the previous snippet out into a call being made to a service object.


RACSignal *incrementNumberSignal = [[[buttonTappedSignal
    map:^id(id x) {
        // Returns a signal that will send the new total value
        return [service incrementTotal];
    }]
    concat]
    doNext:^(NSNumber *total) {
        NSLog(@"The new total is: %@", total);
    }];

With this implementation it doesn’t matter how long it takes to get the new total from the API; the second button tap won’t be processed until the first completes, thanks to -concat.

Conclusion

Like many problems I solve with ReactiveCocoa, once I figure it out, the answer seems so obvious. But until you find yourself always thinking in signals, and signals of signals it can be difficult to pull together the right operators to achieve a desired effect.

Using -map: and -concat together is a simple (if not completely obvious at first) technique for serializing operations. It results in a signal of values (meaning you have something that could be bound to a user interface element like a label) and doesn’t require any blocking calls (which can result in deadlock if you’re not careful).