Using WebSockets to keep a local cache in sync

Sunday, February 9, 2014

Introduction

Every now and then you find yourself in a situation were you have to make the most out of the few resources you have available - at least I did!

One of my projects was having considerable performance problems, problems I realised were mainly due to a very slow network - it could take something between 2 to 5 seconds to retrieve 300kb, something that was causing a really sluggish user experience.

Given that the client didn't want to move his server for is current hosting provider, but was complaining all the time of how slow things were, I realised that I had to find a way to improve application responsiveness by having the data already stored locally when the user/UI actually needs it.

So, devised a strategy of keeping a local cache and using web sockets to notify my local cache that local data as dirty and needs to be updated.


What I was set to accomplish

In my javascript, I wanted to have a convenient way of registering clients to listen to changes in server data. Sintaticaly, I was aiming for something like this:

Cache.on(<event>, <db table/entity>, <callback>);

So, for instance, if I want to listen to updates in orders, I would:

Cache.on('update', 'orders', function (orders, order) {
   // do something
});

So, lets get into it...

Supporting WebSocket requests

Browsers may not support for it yet

We have to be careful about it. Many browser versions don't have support for it (IE supports it on version 10 and above). I was able to use it because system requirements already were for the usage of browsers that are WebSocket enabled.
Project was using Play 2.0.3, which already comes with support for web sockets natively. So, server side, things were pretty easy to accommodate.
I just needed to create a new service (or action in playframework terminology) for browsers to register their web socket.

    
Admin.java
public static WebSocket listen() { User user = getUser(); // Unfortunately, @Security validation is not performed // when WebSocket is returned, so this validation is // actually required (1) if (!user.powerUser) return null; return new WebSocket() { // Called when the Websocket Handshake is done. public void onReady(final WebSocket.In in, final WebSocket.Out out) { NotificationManager.register(in, out); // called when browser window is closed in.onClose(new F.Callback0() { @Override public void invoke() throws Throwable { NotificationManager.unregister(in, out); } }); } }; }

Security Warning (1)

For those using Play (at least version 2.0.3) and play @Security annotation, WebSocket actions are not actually being validated by Play. So, that (callout 1) is definitely required.

What this service will do is register all WebSocket connections in my notification manager.

    
NotificationManager.java
private static List clients = new ArrayList(10); /* ... */ public static void register(WebSocket.In in, WebSocket.Out out) { clients.add(new WebClient(in, out)); Logger.info("Number of sockets:" + clients.size()); } public static void unregister(WebSocket.In in, WebSocket.Out out) { for (WebClient client: clients) { if (client.match(in, out)) { client.close(); clients.remove(client); break; } } Logger.info("Number of sockets:" + clients.size()); } public static void notifyClients(String topic, String type, String data) { ObjectNode message = Json.newObject(); message.put("topic", topic); message.put("type", type); message.put("data", data); for (WebClient client : clients) { client.notify(message); // it will basically call this.out.write(message); } }

Every time some of the local maintained entities would get updated, I will trigger a notification to all clients to mark their cache as dirty.

 // Send notification of order created
NotificationManager.notifyClients("orders", "created", order.toJson());

My local cache

Conceptually, I like the idea of having my cache resembling a database and and an ORM implementation. Having that I mind, my implementation would require the following components:
 - An Entity, that will map to a server side collection of data (usually a table but can actually be just a view)
 - A local cache/db, that will store a set of entities and provide convenient API's to access them

Entity

In modern JavaScript development frameworks it is common to find implementations of the observer pattern, providing a convenient way for developers to ensure that the UI is in sync with the underling model.
So, besides providing a common API to fetch data and refresh data, Entity will also provide a method to register observers and to trigger notifications every time an observer needs to be  notified.

    
Entity.js
/** * Constructor for the entity * * @param name name of the entity * @param loadFn Function to use to retrieve data from the server */ var Entity = function(name, loadFn) { this.table = name; this.data = []; this._fnRemoteCall = loadFn; // list of callbacks waiting for fetch to finish this._bindings = []; // all listeners for "any" load that is triggered this._listeners = { null: [] }; }; /** * Refresh, just call's _load again */ Entity.prototype.refresh = function () { return this._load(); }; /** * Get data for the entity. If entity is dirty or already * waiting from server to responde, wait until data is retrieved; * if not, call callback directly */ Entity.prototype.fetch = function (callback) { if (this.state === 'ready') { callback(this.data); } else if (this.state = 'dirty') { this._bindings.push(callback); this._load(null); } else if (this.state = 'fetching') { this._bindings.push(callback); } }; /** * Register observer * * @param event The type of event to listen to (created/updated/...) * @param callback Callback for when event is triggered */ Entity.prototype.on = function (event, callback) { if (this._listeners[event] === undefined) { this._listeners[event] = []; } this._listeners[event].push(callback); }; /** * Trigger notification to all registered observers * * @param event The type of event to trigger notification * @param data Data to be sent to the callback */ Entity.prototype.trigger = function (event, data) { var listeners = this._listeners[event] || []; for (var i = 0; i < listeners.length; i++) { listeners[i].apply({}, [this.data, data]); } }; /** * Load data from server, using method provided at init * to fetch data from the server */ Entity.prototype._load = function () { var self = this; self.state = 'fetching'; $.when(this._fnRemoteCall()).then(function (data) { self.data = data; self.state = 'ready'; // call callbacks "added" while data was being fetched while (self._bindings.length > 0) { (self._bindings.pop()).apply({}, [self.data]); } self.trigger('loaded'); }); };

Now, to create an entity, I only need to pass a function to be used to fetch data from the server. For it to work properly, it needs to return a promise (I'm use jQuery deferred API).

    var Orders = new Entity('orders', function load() {
        var deferred = $.Deferred();
        utils.getJson('/admin/orders', function (response, ok) {
            deferred.resolve(response.data, ok);
        });
        return deferred.promise();
    });


Cache

Cache will be responsible for the communication with the web socket and for keeping track of all the all entities to be locally kept.

define(['jquery', 'core/utils'], function ($, utils) {


    // create "kind of" a database
    // ---------------------------
    var schema = {
        /* ... */
        orders: Orders
    };

    var get = function(entity) {
        if (schema[entity]) {
            return schema[entity];
        } else {
            throw 'unknown entity';
        }
    };



    // listen to server notifications
    // ------------------------------
    var WS = window['MozWebSocket'] ? MozWebSocket : WebSocket;
    var Socket = new WS(document.NotificationWebSocketUrl); // URL for the action above 
    /**
     * Event data will hold the following structure:
     *   topic: corresponds to the entity affected
     *   type: type of update
     *
     * Currently, every time an event is received, corresponding entity is refreshed!
     *
     * @param event
     */
    Socket.onmessage = function (event) {
        var payload = JSON.parse(event.data)
            , entity = getTable(payload.topic)
            , data;

        // default action - refresh
        entity.refresh(payload.type);

        if (payload.data) {
            data = JSON.parse(payload.data);
        }
        entity.trigger(payload.type, data);
    };



    // public API
    return {

        loadAll: function () {
            $.each(schema, function (name, entity) {
                entity.fetch(function () {
                });
            });
        },

        load: function (name, callback) {
            var entity = get(name);
            entity.fetch(callback);
            return entity;
        },

        listen: function (name, callback) {
            var entity = get(name);
            entity.on(null, callback);
            return entity;
        },

        on: function (event, name, callback) {
            var entity = get(name);
            entity.on(event, callback);
            return entity;
        },

        setDirty: function (name) {
            var entity = get(name);
            entity.state = 'dirty';
            return entity;
        }
    }


});


Putting it all together

With everything set in place, every time a new order arrives, I want to notify and, in case user is looking at stock data, I want to update quantities accordingly.
So, I could just:

    Cache.on('created', 'orders', function(orders, message) {
       // show user a notification 
       utils.showNotification('Order ' + message.order.id + ' arrived');

       // update orders counter in header
       header.incrementOrdersPending();
   

       // update stock
       stock.update(message.order.lines);
    });




Final thoughts

Before you even get started, bare in mind that this is a simplified glimpse of the actual implementation, one that is not suitable for systems coping with large volumes of data.

This as been running for a couple months already and users are very happy with the results. They feel confident to have their browsers open all day without refreshing and after each entity being loaded into the cache, navigation gets very smooth without ever user experiencing any network latency.

Server wise, things are cool as well - one of my concerns was that connections may be kept open after a user closing the browser, but so far I haven't experienced any problem and connections are closing when user is logging out or closing the browser.





No comments :