Socket.io rooms and Redis

There is a great post by Aravind Sathappan on managing single-user-multi-connect issues over at Coderwall: https://coderwall.com/p/ekrcyw

I actually didn't find his post until after I implemented my own solution, but the underlying mechanics are similar. Rather than use socket.io rooms to store user socket ID's, I am storing them directly in a Redis set. I am only using socket.io rooms to store socket ID's for the channel (so I can broadcast to all sockets in the channel). While I do solve the single-user-multi-connect problem in my own use case, it's not as clean as it could be -- socket.io rooms can help clean it up. Let me set up the use case:

A user will land on a page, the URI for that page is used to construct a new socket.io room that the user joins:

io.sockets.on('connection', function (client) {  
    client.on('subscribe', co(function* (msg) {
        client.join(msg.channel);
...

In this block the msg object passed from the client contains our user and channel data. The msg.channel property will contain the result of something like:

window.location.pathname.split('/')[4]  

When any user hits that same URI they will be joined to the same socket.io room, and this allows us to broadcast to all users in that room via:

io.sockets.in(msg.channel).emit('message', users);  

The users object in this case contains the contents of a Redis set which stores all users in the channel given by msg.channel. To further illustrate, let me add to the previous snippet:

io.sockets.on('connection', function (client) {  
    client.on('subscribe', co(function* (msg) {
        client.join(msg.channel);
        store.sadd(msg.channel, msg.user);
...

store is a Redis client provided by node-redis. (More specifically it is a Redis client wrapped by co-redis. My thoughts on co are another post entirely.)

Ok so far so good. If a user hits the URI, we add their socket to the msg.channel room and add the username specified by msg.user to the Redis set using the key msg.channel. We now have a list of sockets for the channel and a list of connected users in the channel.

The problem with single-user-multi-connect is that if a user decides to open the same URI in another browser tab or window, then a new socket is added to the socket.io room, but the user list stored in the Redis set does not change. For example, Jim goes to http://example.com/test123 in his browser:

  1. A socket is created and added to the "test123" room.
  2. The username "Jim" is added to the Redis set for "test123"
  3. The contents of the Redis set "test123" are broadcast to all sockets in the room for "test123"

Now Jim opens up http://example.com/test123 in another tab:

  1. A socket is created and added to the "test123" room.
  2. The username "Jim" already exists in the Redis set for "test123", so nothing gets added.
  3. The contents of the Redis set "test123" are broadcast to all sockets in the room for "test123" (no change)

As Aravind pointed out in his post, the crucial issue here is with the socket.io disconnect handler. When Jim disconnects we remove him from the Redis set for "test123", since he is no longer on that page, then we broadcast the new list of users to the room. (The logic for removing Jim from the room can be seen at the end of this post, just assume I have a way to remove the user from Redis when their socket disconnects).

If Jim is viewing the page in two separate windows, then a disconnect on one page will remove him from the user list even though he is still viewing the page in another window. The question is how do we check if Jim has multiple connections open on the same page?

Aravind suggests adding the user to its own socket.io room. In my use case, it looks like this:

io.sockets.on('connection', function (client) {  
    client.on('subscribe', co(function* (msg) {
        client.join(msg.channel);
        client.join(msg.user);
        store.sadd(msg.channel, msg.user);
...

If I compare the socket ID's in the msg.channel room with the socket ID's in the msg.user room, then I can see if the user has multiple sockets open on the same page. If the user has no more sockets on the page, then I remove the user from that page's list of users (removed from the Redis set for msg.channel).

Now initially I didn't use socket.io rooms to check for multiple connections from a single user. Instead I used Redis directly, probably in a manner similar to how socket.io works under the hood with the Redis store. Every time a user connects, I add the socket ID into two Redis sets: one for msg.channel:clients (as opposed to msg.channel:users) and one for msg.user. When a user disconnects, I intersect the contents of msg.channel:clients with msg.user, and if the resulting array has length 0 then I can rest assured that the user has no other sockets open on the page. I could use the exact same logic with socket.io rooms. The problem with my Redis approach is that I have to clean up the socket ID's manually on each disconnect, whereas socket.io will remove a socket ID from a room automatically on disconnect.

Thanks to Aravind's suggestion, I am going to try and use socket.io rooms to solve the single-user-multi-connect problem and see how well it compares to my own approach. In other words, rather than use Redis directly to store socket ID's in sets, I can intersect the results of socket.io rooms and avoid messy Redis commands in my socket handlers.

Here are what the connect and disconnect handlers look like using socket.io rooms:

io.sockets.on('connection', function (client) {

  client.on('subscribe', co(function* (msg) {

    //Client connects and joins the room for the current channel (URI)
    client.join(msg.channel);

    //Add socket ID to room for user. We can intersect
    //the clients in both this room and the channel room
    //to see if a user has multiple connections to the same channel
    client.join(msg.user);

    //Add user to channel set, this set is broadcast to all users in the channel
    store.sadd(msg.channel, msg.user);

    //Set a hashtable for each client so we can look up
    //the user and channel when they disconnect
    store.hmset(client.id, {'user': msg.user, 'channel': msg.channel});

    //Broadcast user list to channel
    try {
      var users = yield store.smembers(msg.channel);
      io.sockets.in(msg.channel).emit('message', users);
    } catch (ex) { console.dir(ex); }
  }));

  client.on('disconnect', co(function* () {

    //Get reference to disconnected user and channel they left
    var user = yield store.hget(client.id, 'user');
    var channel = yield store.hget(client.id, 'channel');

    //Delete socket hash since the user is gone
    yield store.del(client.id);

    //Get number of sockets the user still has open
    var userClients = io.sockets.clients(user);

    //Get number of sockets still active in this room
    var channelClients = io.sockets.clients(channel);

    //If there are no user-owned sockets left in the channel
    //then remove user from the channel's user list
    var sockets = _.intersection(userClients, channelClients);
    if (sockets.length === 0) yield store.srem(channel, user);

    //Broadcast user list to channel
    try {
      var users = yield store.smembers(channel);
      io.sockets.in(channel).emit('message', users);
    } catch (ex) { console.dir(ex); }
  }));
});