Real-Time Resource Locking Using Websockets and Navigation
For one of our clients, one of the most famous French TV channel, we've built a large administration tool. This tool allows to manage a resource named "teaser". It is used on all our customer platforms, including mobile apps, TV apps and several websites.
For consistency / security reasons, two customer employees must not be able to edit the same teaser at the same time in the admin.
That's why we developed a locking system, which prevents two users from accessing the same ressource at the same time. This system must be reactive, and benefit from real time updates in the UI.
Higher-Order Locking Concept
In computer science, resource locking, most commonly called "lock" or "mutex", consists in a synchronization mechanism for enforcing limits on access to a resource in a multi-threaded environment.
The simplest type of lock is a binary semaphore. It provides exclusive access to the locked data for an arbitrary count of concurrent lockers.
On the other hand, the most frequently encountered lock type is called mutex (mutual exclusion) or sometimes semaphore(1). It can be defined as a semaphore with an arbitrary locker count set to 1, and which can only be released by the initial locker. It can be compared to a simple boolean / switch (1 => unlocked / available), 0 => locked / unavailable).
In the literature, these two structures usually accept two operations:
- P (Claim / Decrease) => Decrease available lock slot count (or lock resource for mutex)
- V (Release / Increase) => Increase available lock slot count (or unlock resource for mutex)
The canonical names V and P come from the initials of Dutch words for historical reasons. V for "verhogen" ("increase") and P for "proberen" ("to try").
Here are two examples below to explain semaphore / mutex working.
Semaphore example for an arbitrary count of 2 allowed concurrent processes:
Mutex example:
Locking into the Browser
Contrary to the previous example, which explains locking between software processes, in my case I must add lock capabilities between multiple browsers / tabs. Although the locking system is broadly the same, the browser environment imposes some limitations.
First, the web is built over an universal fact ; as its name suggests, resource location must be referenced through an Uniform Resource Locator. That implies that locking operations must be triggered by URL accesses (P on page load and V on page unload).
Secondly, given that only the user who has opened the url can release the lock, we can say that a browser tab can be considered as a "process" and the locking system as a "mutex".
Third, as there are multiple locks (one per resource url) and each browser tab must be aware of them, we must setup a central registry of locks, which can be accessed from any browser tab connected to the system. At the same time, this registry must update in real-time, to prevent false positives. It must also reflect locks into the UI as fast as possible.
Server-Side Implementation
Now that requirements and constraints are known, I have to determine how to implement this system in my project.
One of the first constraints of the implementation is to benefit from a central lock "registry". As we already have an expressJS server, we're going to use it to store our locks. As our project has a limited amount of locks and only one server, we can use memory as key/value store for them. For larger projects, it's better to use a shared key/value store like redis or memcache.
Moreover, since our system needs real-time capabilities and is based on javascript, it's deadly simple to plug the famous websocket library (socket.io) which can be used both on client and server.
Websocket is a communication protocol built over TCP to provide full-duplex exchanges. This protocol is standardized by the W3C since 2011 and used by a large and growing number of web apps.
Below, here is the implementation of the lock store with some explanations.
// teaserLock.js (server-side)
let teaserLocks = [];
export const DISCONNECT = "disconnect";
export const TEASER_LOCK_ENTER = "teaser:lock:enter";
export const TEASER_LOCK_LEAVE = "teaser:lock:leave";
export const TEASER_LOCK_LIST = "teaser:lock:list";
// Handler called to broadcast when a change occurs in the lock list
// Useful for UI changes (enable / disable buttons...)
export const emitTeaserLocksChange = socket => {
socket.emit(TEASER_LOCK_LIST, teaserLocks);
};
// Handler called when a client attempts to lock a resource
// => Client passes the wanted "teaserId" to lock and a callback named "notifyLocked" as argument
// => Handler calls "notifyLocked" back with the lock information (already locked or not)
// => Handler adds the lock to the lock list if needed, and broadcasts lock change
export const onTeaserLockEnter = (socket, clientId) => (
{ teaserId },
notifyLocked
) => {
const isTeaserLocked = Boolean(
teaserLocks.find(lt => lt.teaserId === teaserId)
);
notifyLocked(isTeaserLocked);
if (!isTeaserLocked) {
teaserLocks.push({ clientId, teaserId });
emitTeaserLocksChange(socket);
}
};
// Handler called when a client leaves a resource
// => Client passes the "teaserId" to unlock
// => Handler removes the lock from this teaserId for this particular "clientId" (remind "mutex")
// => Handler broadcasts lock change if the teaser lock list has changed
export const onTeaserLockLeave = (socket, clientId) => ({ teaserId }) => {
const initialLength = teaserLocks.length;
teaserLocks = teaserLocks.filter(
lt => !(lt.teaserId === teaserId && lt.clientId === clientId)
);
if (teaserLocks.length !== initialLength) {
emitTeaserLocksChange(socket);
}
};
// Handler called when a client socket connection is broken (or when browser tab is closed)
// => Handler removes locks from the clientId (unique id (per tab) corresponding to socket connection)
// => Handler broadcasts lock change if the teaser lock list has changed
export const onDisconnect = (socket, clientId) => () => {
const initialLength = teaserLocks.length;
teaserLocks = teaserLocks.filter(lt => !(lt.clientId === clientId));
if (teaserLocks.length !== initialLength) {
emitTeaserLocksChange(socket);
}
};
// This function is responsible for the websocket event registration on all lock commands
// TEASER_LOCK_ENTER => P (Claim / Decrease)
// TEASER_LOCK_LEAVE & DISCONNECT => V (Release / Increase)
export const teaserSocketLockHandler = socket => {
socket.on("connection", client => {
client.on(TEASER_LOCK_ENTER, onTeaserLockEnter(socket, client.id));
client.on(TEASER_LOCK_LEAVE, onTeaserLockLeave(socket, client.id));
client.on(DISCONNECT, onDisconnect(socket, client.id));
});
return socket;
};
export default teaserSocketLockHandler;
And here is the expressJS server with "teaserSocketLockHandler" plugged to it.
// server.js (server-side)
import io from "socket.io";
import { teaserSocketLockHandler } from "./teaserLock";
const app = express();
// Other routes...
const server = app.listen(3000, () => {
console.log("Server listening on http://localhost:3000...");
});
// Create a socket and apply teaser locking listener on it
const socket = io(server);
teaserSocketLockHandler(socket);
Client-Side Implementation
Our project is based upon the excellent admin-on-rest framework, which allows us to build an admin in no time on top of an existing REST API.
Given that admin-on-rest is a SPA, all the navigation is achieved programmatically without any page refresh. Therefore, we must setup a listener on route events to be able to handle locking.
As admin-on-rest already implements an event dispatcher on routing through react-router-redux, we can listen to route changes with redux-saga and dispatch lock commands from there.
// teaserSagas.js (client-side)
import { call, takeEvery, takeLatest } from "redux-saga/effects";
import { LOCATION_CHANGE } from "react-router-redux";
import { TEASER_LOCK_ENTER, TEASER_LOCK_LEAVE } from "./teaserLock";
// Some other import...
// On location change, attempt to retrieve teaserId from the current url
// Does nothing if the current url has no teaserId
// Send "TEASER_LOCK_ENTER" to the socket with the teaserId and a Promise resolve as callback
// The socket server will send back to us the locked status of the teaserId
// If the teaser is locked, redirect to index and show a forbidden notification
export function* watchForTeaserLockEnter({ payload }) {
const teaserId = getTeaserIdFromPath(payload.pathname);
if (!teaserId) {
return;
}
const isTeaserLocked = yield call(
() =>
new Promise(resolve => {
clientSocket.emit(TEASER_LOCK_ENTER, { teaserId }, resolve);
})
);
if (isTeaserLocked) {
yield put(redirectToTeasersIndex);
yield put(showTeaserLockedNotification);
}
}
// On location change, attempt to retrieve teaserId from the previous url
// Send "TEASER_LOCK_LEAVE" to the socket if the previous url is a teaser
export function* watchForTeaserLockLeave() {
const previousTeaserId = getTeaserIdFromPath(
yield getPreviousBrowserHistoryPath()
);
if (previousTeaserId) {
clientSocket.emit(TEASER_LOCK_LEAVE, { teaserId: previousTeaserId });
}
}
// Register "watchers" on route change for router enter and route leave
export default function* root() {
yield [
takeEvery(LOCATION_CHANGE, watchForTeaserLockEnter),
takeEvery(LOCATION_CHANGE, watchForTeaserLockLeave),
];
}
You can apply the same watch system in any web page. To do that, you can simply use window.load for "watchForTeaserLockEnter" and window.unload for "watchForTeaserLockLeave".
If you use react-router (v4) and you don't use redux and react-router-redux, you can respectively plug this actions to your classical lifecycle events, "componentDidMount" and "componentWillUnmount".
As you can see, locks are instantaneously reflected into the browser UI thanks to websockets. This way, users are immediately aware of the current resource status, and avoid possible frustration on access.
Conclusion
Although difficult at first sight, the setup of a resource locking into the browser is a relative simple task when we have understood all parties involved in the system.
Moreover, with the help of new ways of communication between server and browser as the websocket protocol, it's an easy job to bring real-time capabilities to our apps.
If I had to sum up what I learned from this project, I'd say that almost everything is possible with today's web technologies.
Let's enjoy!