Permalink
Browse files

Add support for Redis lock

  • Loading branch information...
1 parent 92b9d9d commit ef6bac9286fa297e85c48c546f3664552bdca1e9 @mrvisser mrvisser committed Mar 9, 2014
Showing with 277 additions and 1 deletion.
  1. +1 −1 lib/Redback.js
  2. +122 −0 lib/advanced_structures/Lock.js
  3. +154 −0 test/lock.test.js
View
@@ -24,7 +24,7 @@ var base = ['Hash','List','Set','SortedSet','Bitfield'];
*/
var advanced = ['KeyPair','DensitySet','CappedList','SocialGraph',
- 'FullText', 'Queue', 'RateLimit', 'BloomFilter'];
+ 'FullText', 'Queue', 'RateLimit', 'BloomFilter', 'Lock'];
/**
* The Redback object wraps the Redis client and acts as a factory
@@ -0,0 +1,122 @@
+
+var crypto = require('crypto');
+var Structure = require('../Structure');
+
+var Lock = exports.Lock = Structure.new();
+
+/**
+ * Acquire a temporary lock on some key.
+ *
+ * @param {string} key The unique key of the lock
+ * @param {number} ttl The amount of time (in seconds) before the lock expires
+ * @param {Function} callback Invoked when the process completes
+ * @param {Error} callback.err An error that occurred, if any
+ * @param {string} callback.token The token that was acquired if successful. If the lock was
+ * not acquired then this will be `undefined`
+ * @api public
+ */
+
+Lock.prototype.acquire = function(key, ttl, callback) {
+ var client = this.client;
+
+ _createToken(function(err, token) {
+ if (err) {
+ return callback(err);
+ }
+
+ client.setnx(key, token, function(err, wasSet) {
+ if (err) {
+ return callback();
+ } else if (!wasSet) {
+ // We did not successfully acquire the lock. Since a process can crash after it sets
+ // the lock but before it sets the expiry, we need to avoid deadlocks by ensuring
+ // the lock has a TTL associated to it
+ _ensureTtl(client, key, ttl);
+ return callback();
+ }
+
+ // Apply the expiry to the lock
+ client.expire(key, ttl, function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ // Return the token, which is used to release the lock
+ return callback(null, token);
+ });
+ });
+ });
+};
+
+/**
+ * Release a lock that was acquired with the provided key and token.
+ *
+ * @param {string} key The key for the lock to release
+ * @param {string} token The token that was generated for the lock acquisition
+ * @param {Function} callback Invoked when the function completes
+ * @param {Error} callback.err An error that occurred, if any
+ * @param {boolean} callback.hadLock Determines whether or not we owned the lock at the time
+ * that we released it
+ * @api public
+ */
+Lock.prototype.release = function(key, token, callback) {
+ var client = this.client;
+
+ client.get(key, function(err, lockedToken) {
+ if (err) {
+ return callback(err);
+ } else if (lockedToken !== token) {
+ // The current token is not the one we acquired. It's possible we held the lock longer
+ // than its expiry
+ return callback(null, false);
+ }
+
+ // We have the token, simply delete the lock key
+ client.del(key, function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ return callback(null, true);
+ });
+ });
+};
+
+/**
+ * Ensure the lock with the given key has a `ttl`. If it does not, the given expiry will be applied
+ * to it.
+ *
+ * @param {RedisClient} client The Redis to use to apply the ttl
+ * @param {string} key The key of the lock to check
+ * @param {number} ttl If the lock does not have an expiry set, set this duration (in
+ * seconds)
+ * @api private
+ */
+
+var _ensureTtl = function(client, key, ttl) {
+ client.ttl(key, function(err, currentTtl) {
+ if (currentTtl === -1) {
+ // There is no expiry set on this key, set it
+ client.expire(key, ttl);
+ }
+ });
+};
+
+/**
+ * Generate a random lock token.
+ *
+ * @param {Function} callback Invoked with the token when complete
+ * @param {Error} callback.err An error that occurred, if any
+ * @param {string} callback.token The randomly generated token
+ * @api private
+ */
+
+var _createToken = function(callback) {
+ crypto.randomBytes(16, function(err, buffer) {
+ if (err) {
+ return callback(err);
+ }
+
+ return callback(null, buffer.toString('base64'));
+ });
+};
View
@@ -0,0 +1,154 @@
+var redback = require('../').createClient(),
+ assert = require('assert');
+
+// Flush the DB and close the Redis connection after 2 seconds
+setTimeout(function () {
+ // Ensure we completed all tests
+ assert.strictEqual(_testsCompleted, Object.keys(module.exports).length);
+ redback.client.flushdb(function (err) {
+ redback.client.quit();
+ });
+}, 2000);
+
+var TEST_LOCK = 'test lock';
+
+var _testsCompleted = 0;
+var _complete = function() {
+ _testsCompleted++;
+};
+
+module.exports = {
+
+ 'test lock cannot be stolen': function() {
+ var key = 'test-lock-cannot-be-stolen';
+ var lock = redback.createLock(TEST_LOCK);
+
+ lock.acquire(key, 1, function(err, token) {
+ assert.ok(!err);
+ assert.ok(token);
+
+ // Ensure we don't get a token the next time we try and acquire
+ lock.acquire(key, 1, function(err, token) {
+ assert.ok(!err);
+ assert.ok(!token);
+ _complete();
+ });
+ });
+ },
+
+ 'test lock can be re-acquired after release': function() {
+ var key = 'test-lock-can-be-re-acquired-after-released';
+ var lock = redback.createLock(TEST_LOCK);
+
+ lock.acquire(key, 1, function(err, token) {
+ assert.ok(!err);
+ assert.ok(token);
+
+ lock.release(key, token, function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(hadLock);
+
+ // Ensure we do get a token the next time we try and acquire
+ lock.acquire(key, 1, function(err, token) {
+ assert.ok(!err);
+ assert.ok(token);
+ _complete();
+ });
+ });
+ });
+ },
+
+ 'test lock release indicates when releasing with invalid token': function() {
+ var key = 'test-lock-release-indicates-when-releasing-with-invalid-token';
+ var lock = redback.createLock(TEST_LOCK);
+
+ lock.acquire(key, 1, function(err, token) {
+ assert.ok(!err);
+ assert.ok(token);
+
+ // Ensure that releasing with an invalid token indicates we did not
+ // have the lock
+ lock.release(key, 'not the token', function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(!hadLock);
+
+ lock.release(key, token, function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(hadLock);
+
+ // Ensure re-release indicates something is wrong
+ lock.release(key, token, function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(!hadLock);
+ _complete();
+ });
+ });
+ });
+ });
+ },
+
+ 'test lock can be re-acquired after expiry': function() {
+ var key = 'test-lock-can-be-re-acquired-after-expiry';
+ var lock = redback.createLock(TEST_LOCK);
+
+ lock.acquire(key, 1, function(err, firstToken) {
+ assert.ok(!err);
+ assert.ok(firstToken);
+
+ setTimeout(function() {
+
+ // Ensure we can acquire the lock again
+ lock.acquire(key, 1, function(err, secondToken) {
+ assert.ok(!err);
+ assert.ok(secondToken);
+
+ // Ensure we cannot release it with the old token
+ lock.release(key, firstToken, function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(!hadLock);
+
+ // Ensure we cannot re-acquire since it is still held
+ // by secondToken
+ lock.acquire(key, 1, function(err, thirdToken) {
+ assert.ok(!err);
+ assert.ok(!thirdToken);
+
+ // Ensure we can successfully release the lock with
+ // the `secondToken`
+ lock.release(key, secondToken, function(err, hadLock) {
+ assert.ok(!err);
+ assert.ok(hadLock);
+ _complete();
+ });
+ });
+ });
+ });
+
+ }, 1250);
+ });
+ },
+
+ 'test concurrent locks results in only one acquisition': function() {
+ var key = 'test-concurrent-locks-results-in-only-one-acquisition';
+ var lock = redback.createLock(TEST_LOCK);
+
+ var numAcquired = 0;
+ var numCompleted = 0;
+ var iterations = 10;
+ var acquireComplete = function(err, token) {
+ numCompleted++;
+ if (token) {
+ numAcquired++;
+ }
+
+ if (numCompleted === iterations) {
+ assert.strictEqual(numAcquired, 1);
+ _complete();
+ }
+ };
+
+ for (var i = 0; i < iterations; i++) {
+ lock.acquire(key, 1, acquireComplete);
+ }
+ }
+};

0 comments on commit ef6bac9

Please sign in to comment.