UNPKG

bull-redlock

Version:

A node.js redlock implementation for distributed redis locks

354 lines (276 loc) 10.3 kB
'use strict'; var util = require('util'); var crypto = require('crypto'); var Promise = require('bluebird'); var EventEmitter = require('events'); // support the event library provided by node < 0.11.0 if(typeof EventEmitter.EventEmitter === 'function') EventEmitter = EventEmitter.EventEmitter; // constants var lockScript = 'return redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])'; var unlockScript = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'; var extendScript = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end'; // defaults var defaults = { driftFactor: 0.01, retryCount: 10, retryDelay: 400, retryJitter: 400 }; // LockError // --------- // This error is returned when there is an error locking a resource. function LockError(message) { Error.call(this); Error.captureStackTrace(this, LockError); this.name = 'LockError'; this.message = message || 'Failed to lock the resource.'; } util.inherits(LockError, Error); // Lock // ---- // An object of this type is returned when a resource is successfully locked. It contains // convenience methods `unlock` and `extend` which perform the associated Redlock method on // itself. function Lock(redlock, resource, value, expiration) { this.redlock = redlock; this.resource = resource; this.value = value; this.expiration = expiration; } Lock.prototype.unlock = function unlock(callback) { return this.redlock.unlock(this, callback); }; Lock.prototype.extend = function extend(ttl, callback) { return this.redlock.extend(this, ttl, callback); }; // Attach a reference to Lock, which allows the application to use instanceof // to ensure type. Redlock.Lock = Lock; // Redlock // ------- // A redlock object is instantiated with an array of at least one redis client and an optional // `options` object. Properties of the Redlock object should NOT be changed after it is first // used, as doing so could have unintended consequences for live locks. function Redlock(clients, options) { // set default options options = options || {}; this.driftFactor = typeof options.driftFactor === 'number' ? options.driftFactor : defaults.driftFactor; this.retryCount = typeof options.retryCount === 'number' ? options.retryCount : defaults.retryCount; this.retryDelay = typeof options.retryDelay === 'number' ? options.retryDelay : defaults.retryDelay; this.retryJitter = typeof options.retryJitter === 'number' ? options.retryJitter : defaults.retryJitter; this.lockScript = typeof options.lockScript === 'function' ? options.lockScript(lockScript) : lockScript; this.unlockScript = typeof options.unlockScript === 'function' ? options.unlockScript(unlockScript) : unlockScript; this.extendScript = typeof options.extendScript === 'function' ? options.extendScript(extendScript) : extendScript; this.extraLockKeys = options.extraLockKeys; this.extraLockArgs = options.extraLockArgs; // set the redis servers from additional arguments this.servers = clients; if(this.servers.length === 0) throw new Error('Redlock must be instantiated with at least one redis server.'); } // Inherit all the EventEmitter methods, like `on`, and `off` util.inherits(Redlock, EventEmitter); // Attach a reference to LockError per issue #7, which allows the application to use instanceof // to destinguish between error types. Redlock.LockError = LockError; // lock // ---- // This method locks a resource using the redlock algorithm. // // ```js // redlock.lock( // 'some-resource', // the resource to lock // 2000, // ttl in ms // function(err, lock) { // callback function (optional) // ... // } // ) // ``` Redlock.prototype.acquire = Redlock.prototype.lock = function lock(resource, ttl, callback) { return this._lock(resource, null, ttl, callback); }; // lock // ---- // This method locks a resource using the redlock algorithm, // and returns a bluebird disposer. // // ```js // using( // redlock.disposer( // 'some-resource', // the resource to lock // 2000 // ttl in ms // ), // function(lock) { // ... // } // ); // ``` Redlock.prototype.disposer = function disposer(resource, ttl, errorHandler) { errorHandler = errorHandler || function(err) {}; return this._lock(resource, null, ttl).disposer(function(lock){ return lock.unlock().catch(errorHandler); }); }; // unlock // ------ // This method unlocks the provided lock from all servers still persisting it. It will fail // with an error if it is unable to release the lock on a quorum of nodes, but will make no // attempt to restore the lock on nodes that failed to release. It is safe to re-attempt an // unlock or to ignore the error, as the lock will automatically expire after its timeout. Redlock.prototype.release = Redlock.prototype.unlock = function unlock(lock, callback) { var self = this; return new Promise(function(resolve, reject) { // immediately invalidate the lock lock.expiration = 0; // the number of servers which have agreed to release this lock var votes = 0; // the number of votes needed for consensus var quorum = Math.floor(self.servers.length / 2) + 1; // the number of async redis calls still waiting to finish var waiting = self.servers.length; // release the lock on each server self.servers.forEach(function(server){ server.eval(self.unlockScript, 1, lock.resource, lock.value, loop); }); function loop(err, response) { if(err) self.emit('clientError', err); // - if the lock was released by this call, it will return 1 // - if the lock has already been released, it will return 0 // - it may have been re-acquired by another process // - it may hava already been manually released // - it may have expired if(typeof response === 'number' && (response === 0 || response === 1)) votes++; if(waiting-- > 1) return; // SUCCESS: there is concensus and the lock is released if(votes >= quorum) return resolve(); // FAILURE: the lock could not be released return reject(new LockError('Unable to fully release the lock on resource "' + lock.resource + '".')); } }) // optionally run callback .nodeify(callback); }; // extend // ------ // This method extends a valid lock by the provided `ttl`. Redlock.prototype.extend = function extend(lock, ttl, callback) { var self = this; // the lock has expired if(lock.expiration < Date.now()) return Promise.reject(new LockError('Cannot extend lock on resource "' + lock.resource + '" because the lock has already expired.')).nodeify(callback); // extend the lock return self._lock(lock.resource, lock.value, ttl) // modify and return the original lock object .then(function(extension){ lock.value = extension.value; lock.expiration = extension.expiration; return lock; }) // optionally run callback .nodeify(callback); }; // _lock // ----- // This method locks a resource using the redlock algorithm. // // ###Creating New Locks: // // ```js // redlock._lock( // 'some-resource', // the resource to lock // null, // no original lock value // 2000, // ttl in ms // function(err, lock) { // callback function (optional) // ... // } // ) // ``` // // ###Extending Existing Locks: // // ```js // redlock._lock( // 'some-resource', // the resource to lock // 'dkkk18g4gy39dx6r', // the value of the original lock // 2000, // ttl in ms // function(err, lock) { // callback function (optional) // ... // } // ) // ``` Redlock.prototype._lock = function _lock(resource, value, ttl, callback) { var self = this; return new Promise(function(resolve, reject) { var request; // the number of times we have attempted this lock var attempts = 0; // create a new lock if(value === null) { value = self._random(); var keys = [resource].concat(self.extraLockKeys || []); var args = [value, ttl].concat(self.extraLockArgs || []); var cmd = 'redlockLock' + (self.extraLockKeys ? 'Extra' : ''); request = function(server, loop){ if(!server[cmd]){ server.defineCommand(cmd, { numberOfKeys: keys.length, lua: self.lockScript }); } return server[cmd].apply(server, keys.concat(args).concat([loop])); }; } // extend an existing lock else { request = function(server, loop){ return server.eval(self.extendScript, 1, resource, value, ttl, loop); }; } function attempt(){ attempts++; // the time when this attempt started var start = Date.now(); // the number of servers which have agreed to this lock var votes = 0; // the number of votes needed for consensus var quorum = Math.floor(self.servers.length / 2) + 1; // the number of async redis calls still waiting to finish var waiting = self.servers.length; function loop(err, response) { if(err) self.emit('clientError', err); if(response) votes++; if(waiting-- > 1) return; // Add 2 milliseconds to the drift to account for Redis expires precision, which is 1 ms, // plus the configured allowable drift factor var drift = Math.round(self.driftFactor * ttl) + 2; var lock = new Lock(self, resource, value, start + ttl - drift); // SUCCESS: there is concensus and the lock is not expired if(votes >= quorum && lock.expiration > Date.now()) return resolve(lock); // remove this lock from servers that voted for it return lock.unlock(function(){ // RETRY if(attempts <= self.retryCount) return setTimeout(attempt, self.retryDelay + Math.floor((Math.random() * 2 - 1) * self.retryJitter)); // FAILED return reject(new LockError('Exceeded ' + self.retryCount + ' attempts to lock the resource "' + resource + '".')); }); } return self.servers.forEach(function(server){ return request(server, loop); }); } return attempt(); }) // optionally run callback .nodeify(callback); }; Redlock.prototype._random = function _random(){ return crypto.randomBytes(16).toString('hex'); }; module.exports = Redlock;