@microfleet/ioredis-lock
Version:
Node distributed locking using redis with ioredis adapter
178 lines • 6.16 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { LockAcquisitionError, LockReleaseError, LockExtendError } from './errors.js';
import { setTimeout as delay } from 'node:timers/promises';
import * as scripts from './scripts.js';
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
/**
* @class Lock
*/
export class Lock {
static _acquiredLocks = new Set();
_id = randomUUID();
_client;
_locked = false;
_key = null;
config = {
timeout: 10000,
retries: 6,
delay: 50,
jitter: 1.2
};
/**
* The constructor for a Lock object. Accepts both a redis client, as well as
* an options object with the following properties: timeout, retries and delay.
* Any options not supplied are subject to the current defaults.
* @constructor
*
* @param {RedisClient} client The node_redis client to use
* @param {object} options
*
* @property {int} timeout Time in milliseconds before which a lock expires
* (default: 10000 ms)
* @property {int} retries Maximum number of retries in acquiring a lock if the
* first attempt failed (default: 0)
* @property {int} delay Time in milliseconds to wait between each attempt
* (default: 50 ms)
*/
constructor(client, options) {
this._client = client;
Object.defineProperty(this, '_client', { enumerable: false });
if (options && typeof options === 'object') {
Object.assign(this.config, options);
}
if (this.config.jitter < 1) {
process.emitWarning('jitter must be above or eq 1', 'IoredisLock', 'WARN001');
this.config.jitter = 1;
}
this._setupClient();
}
/**
* Attempts to acquire a lock, given a key, and an optional callback function.
* If the initial lock fails, additional attempts will be made for the
* configured number of retries, and padded by the delay. The callback is
* invoked with an error on failure, and returns a promise if no callback is
* supplied. If invoked in the context of a promise, it may throw a
* LockAcquisitionError.
*
* @param key The redis key to use for the lock
*/
async acquire(key) {
if (this._locked) {
throw new LockAcquisitionError('Lock already held');
}
try {
await this._attemptLock(key, this.config.retries);
this._locked = true;
this._key = key;
Lock._acquiredLocks.add(this);
}
catch (err) {
if (!(err instanceof LockAcquisitionError)) {
throw new LockAcquisitionError(err.message);
}
throw err;
}
return this;
}
/**
* Attempts to extend the lock
* @param expire in `timeout` seconds
*/
async extend(time = this.config.timeout) {
const key = this._key;
const client = this._client;
if (!this._locked || !key) {
throw new LockExtendError('Lock has not been acquired');
}
try {
const res = await client.pexpireifequal(key, this._id, time);
if (res) {
return this;
}
this._locked = false;
this._key = null;
Lock._acquiredLocks.delete(this);
throw new LockExtendError(`Lock on "${key}" had expired`);
}
catch (err) {
if (!(err instanceof LockExtendError)) {
throw new LockExtendError(err.message);
}
throw err;
}
}
/**
* Attempts to release the lock, and accepts an optional callback function.
* The callback is invoked with an error on failure, and returns a promise
* if no callback is supplied. If invoked in the context of a promise, it may
* throw a LockReleaseError.
*/
async release() {
const key = this._key;
const client = this._client;
if (!this._locked || !key) {
throw new LockReleaseError('Lock has not been acquired');
}
try {
const res = await client.delifequal(key, this._id);
this._locked = false;
this._key = null;
Lock._acquiredLocks.delete(this);
if (!res) {
throw new LockReleaseError(`Lock on "${key}" had expired`);
}
}
catch (err) {
// Wrap redis errors
if (!(err instanceof LockReleaseError)) {
throw new LockReleaseError(err.message);
}
throw err;
}
return this;
}
/**
* @private
*/
_setupClient() {
const client = this._client;
if (!client.delifequal) {
client.defineCommand('delifequal', {
lua: scripts.delifequal,
numberOfKeys: 1,
});
}
if (!client.pexpireifequal) {
client.defineCommand('pexpireifequal', {
lua: scripts.pexpireifequal,
numberOfKeys: 1,
});
}
}
/**
* Attempts to acquire the lock, and retries upon failure if the number of
* remaining retries is greater than zero. Each attempt is padded by the
* lock's configured retry delay.
*
* @param {string} key The redis key to use for the lock
* @param {int} retries Number of remaining retries
*
* @returns {Promise}
*/
async _attemptLock(key, retries) {
const client = this._client;
const ttl = this.config.timeout;
const res = await client.set(key, this._id, 'PX', ttl, 'NX');
if (!res && retries < 1) {
throw new LockAcquisitionError(`Could not acquire lock on "${key}"`);
}
else if (res) {
return;
}
await delay(this.config.delay * getRandomArbitrary(1, this.config.jitter));
return this._attemptLock(key, retries - 1);
}
}
//# sourceMappingURL=lock.js.map