Centralizing Distributed Locks In Order To Provide Application-Oriented Semantics Around Locking In Node.js
In web application development, locks help to synchronize access to certain pieces of information. We use locks so that we can be confident that parallel requests aren't trying to mutate the same piece of data at the same time. Locking is a concern for both single-server and horizontally-scaled architectures; but, with horizontal scaling, you can no longer depend on the native locking mechanism provided by your programming language. The more locking that I use in an application, the less coherent and consistent my locking strategy tends to become. And, in order to counteract this haphazardness, I've started to centralize my distributed locking logic behind application-oriented semantics. Not only does this keep my locks consistent, it encapsulates common options such as wait-timeout and serves to document the locking landscape.
In a distributed application, you probably have some means of acquiring and releasing a lock through an asynchronous workflow. If we wanted to mock-out a distributed locking implementation, it might look something like this:
// I provide a means to acquire distributed locks that can be used across a horizontally-
// scaled infrastructure.
exports.DistributedLocks = class DistributedLocks {
// I try to create and return a distributed lock with the given name, returns a
// Promise. If the lock can be obtained, the promise is resolved. If the lock CANNOT
// be obtained, the promise is rejected.
// --
// CAUTION: We're just mocking this out for now.
acquire( name, waitTimeout ) {
return( Promise.resolve( new AcquiredLock( name ) ) );
}
};
// I represent an acquired distributed lock and provide a means to release the lock.
class AcquiredLock {
// I initialize the acquired lock.
constructor( name ) {
this.name = name;
}
// ---
// PUBLIC METHODS.
// ---
// I try to release the distributed lock, returns a Promise. If the lock can be
// released, the promise is resolved. If the lock CANNOT be released, the promise
// is rejected.
// --
// CAUTION: We're just mocking this out for now.
release() {
return( Promise.resolve() );
}
}
Here, you can see that we have a service that provides an .acquire() method that uses Promises to obtain a lock with the given name and meta-data. The resultant lock then provides a .release() method that uses Promises to release the lock so that other processes may obtain it. Obviously, the internals here are hard-coded. In reality, you'd probably be contacting a Redis Database or a MongoDB Database in order to persist the distributed lock. But, the implementation isn't relevant to this conversation.
Now, you can use this distributed lock implementation directly within your application logic:
distributedLocks
.acquire( "this-is-my-lock-name", 1000 )
.then(
( lock ) => {
// ... synchronized code goes here.
return( lock.release() );
}
)
;
This is what I've done historically. But, what I find is that I tend to lose a grip on how locking is integrated into my application. My lock names become inconsistent; the wait-timeout for like-named locks becomes inconsistent; and, I lose insight into what kind of data is being synchronized across the application.
To help manage the growing landscape of distributed locking, I've started to move the acquisition of locks into a centralized place in the application. I'm still using a distributed locking implementation under the hood - only now, it's being proxied behind semantically-meaningful methods:
// I provide application-oriented semantics for creating distributed locks. This is
// nothing but a thin wrapper around the given distributed lock implementation that
// presents a centralized list of lock and helps to ensure consistent lock names and
// lock meta-data (such as wait timeout and retry attempts).
exports.AppLocks = class AppLocks {
// I initialize the app locks.
constructor( distributedLocks ) {
this.distributedLocks = distributedLocks;
}
// ---
// PUBLIC METHODS.
// ---
// I create a lock around the address collection for the given user.
forAddressesOwnedByUser( userId ) {
return( this._acquire( `user:${ userId }:addresses`, 1000 ) );
}
// I create a lock around the given shopping cart.
forShoppingCart( cartId ) {
return( this._acquire( `cart:${ cartId }`, 200 ) );
}
// I create a lock around the given user.
forUser( userId ) {
return( this._acquire( `user:${ userId }`, 2000 ) );
}
// ---
// PRIVATE METHODS.
// ---
// I acquire a distributed lock with the given name. This is here to pull the actual
// implementation out of the public class methods.
_acquire( name, waitTimeout ) {
return( this.distributedLocks.acquire( name, waitTimeout ) );
}
};
Here, you can see that I am providing methods that:
- Clearly define the intent, scope, and requirements of the lock.
- Hide common meta-data choices, such as wait-timeout.
Now, if I need to implement a distributed lock in a new application workflow, I can quickly see if the necessary locking method already exists. And, if it does, I can use it. Or, if it doesn't, I can add it to this centralized location, using a naming convention and lock options that are consistent with the existing locks.
To some degree this actually reduces the coupling within your application. Each calling context is still coupled to the locking proxy. But, since the lock names and meta-data have been encapsulated within the proxy, the individual calling contexts are no longer coupled to each other in the sense that the lock naming and options no longer need to be consistent - there is no cross-context "Connascence of Values".
Since the centralized AppLocks class is just a proxy to the DistributeLocks class, it still uses the same Promise-based control flow:
// Require the application modules.
var DistributedLocks = require( "./distributed-locks" ).DistributedLocks;
var AppLocks = require( "./app-locks" ).AppLocks;
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Setup our application models.
var distributedLocks = new DistributedLocks();
var appLocks = new AppLocks( distributedLocks );
// We can always use the Distribute Locks class directly for raw locking.
distributedLocks
.acquire( "user-34", 1000 )
.then(
( lock ) => {
console.log( "The DISTRIBUTED lock has been obtained!" );
// Once we're done with the lock, we can release it.
return( lock.release() );
}
)
;
// Or, we can use the App Locks to obtain locks with application-focused semantics.
// Notice that when we encapsulate the lock implementation, we can make the name more
// explanatory and hide common settings like wait-timeout.
appLocks
.forUser( 34 )
.then(
( lock ) => {
console.log( "The APP lock has been obtained!" );
// Once we're done with the lock, we can release it.
return( lock.release() );
}
)
;
... but, I think you can see that the AppLocks proxy simplifies and elucidates the locking semantics.
By centralizing the acquisition of distributed locks, you're not really changing the functionality of locking within your application; but, you are setting yourself up for greater consistency and easier consumption. The semantics of the application-oriented locking methods also make the code a bit easier to understand, thereby decreasing the cognitive load placed upon the next developer (or the future you).
Want to use code from this post? Check out the license.
Reader Comments