Wednesday, February 23, 2011

Pushing Updates with the Channel API

If you've been watching Best Buy closely, you already know that Best Buy is constantly trying to come up with new and creative ways to use App Engine to engage with their customers. In this guest blog post, Luke Francl, BBYOpen Developer, was kind enough to share with us Best Buy's latest App Engine project.


As part of Best Buy's Connected Store initiative, we have placed QR codes on our product information Fact Tags, in addition to the standard pricing and product descriptions already printed there. When a customer uses the Best Buy app, or any other QR code scanner, they are shown the product details for the product they have scanned, powered by the BBYOpen API or the m.bestbuy.com platform.

To track what stores and products are most popular, QR codes are also encoded with the store number. My project at Best Buy has been to analyze these scans and make new landing pages for QR codes easier to create.

Since we have the geo-location of the stores and product details from our API, it is a natural fit to display these scans on a map. We implemented an initial version of this idea, which used polling to visualize recent scans. To take our this a step further, we thought it would be exciting to use the recently launched App Engine Channel API to update our map in real-time.

Our biggest challenge was pushing the updates to multiple browsers, since we'd most certainly have more than one user at a time looking at our map. The Channel API does not currently support broadcasting a single update to many connected clients. In order to broadcast updates to multiple users, our solution was to keep a list of client IDs and send an update message to each of them.

To implement this, we decided to store the list of active channels in memcache. This solution is not ideal as there are race conditions when we modify the list of client IDs. However, it works well for our demo.

Here’s how we got it working. The code has been slightly simplified for clarity, including removing the rate limiting that we do. To play with a working demo, check out the channel-map-demo project from GitHub.

As customers in our stores scan QR codes, those scans are recorded by enqueuing a deferred. We defer all writes so we can return a response to the client as quickly as possible.

In the deferred, we call a function to push the message to all the active channels (see full source).

def push_to_channels(scan):
    content = '<div class="infowindowcontent">(...) </div>' % {
                  'product_name': scan.product.name,
                  'timestamp' : scan.timestamp.strftime('%I:%M %p'),
                  'store_name': scan.store.name,
                  'state': scan.store.state,
                  'image': scan.product.image }
            
    message = {'lat': scan.store.lat,
               'lon': scan.store.lon,
               'content': content}

    channels = simplejson.loads(memcache.get('channels') or '{}')
    
    for channel_id in channels.iterkeys():
        encoded_message = simplejson.dumps(message)

        channel.send_message(channel_id, encoded_message)

The message is a JSON data structure containing the latitude and longitude of the store where the scan occurred, plus a snippet of HTML to display in an InfoWindow on the map. The product information (such as name and thumbnail image) comes from our BBYOpen Products API.

Then, when a user opens up the site and requests the map page, we create a channel, add it to the serialized channels Python dictionary, stored in memcache, and pass the token back to the client (see full source).

channel_id = uuid.uuid4().hex
token = channel.create_channel(channel_id)

channels = simplejson.loads(memcache.get('channels') or '{}')
    
channels[channel_id] = str(datetime.now())

memcache.set('channels', simplejson.dumps(channels))

On the map page, JavaScript creates a Google Maps map and uses the token to open a channel. When the onMessage callback is called by the Channel API, a new InfoWindow is displayed on the map using the HTML content and latitude and longitude in the message (see full source).

function onMessage(message) {
  var scan = JSON.parse(message.data);

  var infoWindow = new google.maps.InfoWindow(
    {content: scan.content,
     disableAutoPan: true,
     position: new google.maps.LatLng(scan.lat, scan.lon)});

  infoWindow.open(map);
  setTimeout(function() { infoWindow.close(); }, 10000);
};

Finally, since channels can only be open for two hours, we have a cron job that runs once an hour to remove old channels. Before deleting the client ID, a message is sent on the channel which triggers code in the JavaScript onMessage function to reload the page, thus giving it a new channel and client ID (see full source).

You can see the end result on our map, watch a video about the BBYScan project or checkout the sample channel-map-demo project and create your own Channel API based application.

2 comments:

Robocat said...
This comment has been removed by the author.
Jean said...

Nice! I've just released a similar project. I'm using the datastore to keep a list of active clients. Clients ping the server at a regular interval. To speed things up, I'm memcaching this list of active clients (it gets invalidated whenever there is a new client). My code available on GitHub.