UNPKG

@microfleet/ioredis-lock

Version:

Node distributed locking using redis with ioredis adapter

178 lines 6.16 kB
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