UNPKG

@apiratorjs/locking-redis

Version:

An extension to the core @apiratorjs/locking library, providing Redis-based implementations of distributed mutexes and semaphores for true cross-process concurrency control in Node.js.

181 lines (179 loc) 6.97 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RedisDistributedSemaphore = void 0; const node_assert_1 = __importDefault(require("node:assert")); const node_crypto_1 = __importDefault(require("node:crypto")); const locking_1 = require("@apiratorjs/locking"); const constants_1 = require("./constants"); const distributed_releaser_1 = require("./distributed-releaser"); const base_distributed_lock_primitive_1 = require("./base-distributed-lock-primitive"); const redis_script_1 = require("./redis-script"); const RELEASE_SCRIPT = new redis_script_1.RedisScript(` local removed = redis.call('zrem', KEYS[1], ARGV[1]) return removed `); const ACQUIRE_SCRIPT = new redis_script_1.RedisScript(` -- Remove expired locks redis.call('zremrangebyscore', KEYS[1], '-inf', ARGV[1]) -- Check if there are free slots and add the lock in one atomic operation local currentCount = redis.call('zcard', KEYS[1]) if currentCount < tonumber(ARGV[2]) then redis.call('zadd', KEYS[1], ARGV[3], ARGV[4]) -- Set the key to expire if it is not already set to expire sooner local keyTtl = redis.call('pttl', KEYS[1]) if keyTtl < tonumber(ARGV[5]) then redis.call('pexpire', KEYS[1], ARGV[5]) end return 1 end return 0 `); class RedisDistributedSemaphore extends base_distributed_lock_primitive_1.BaseDistributedLockPrimitive { constructor(props) { node_assert_1.default.ok(props.name, "RedisDistributedSemaphore requires a non-empty name."); node_assert_1.default.ok(props.maxCount > 0, "maxCount must be greater than 0"); super({ ...props, name: `${locking_1.ELockDisplayType.Semaphore}:${props.name}` }); this.maxCount = props.maxCount; } async waitForAnyUnlock() { this.ensureAlive(); return this.waitForUnlockEvent(async () => { // Treat destroy as unlocked so in-flight `:release` notify checks do not // throw LockNotFoundError via freeCount() after destroyed is set. if (this.destroyed) { return true; } return (await this.freeCount()) > 0; }); } async waitForFullyUnlock() { this.ensureAlive(); return this.waitForUnlockEvent(async () => { if (this.destroyed) { return true; } return (await this.freeCount()) === this.maxCount; }); } async destroy(message) { if (this.destroyed) { return; } this.destroyed = true; await this.redisClient.del(this.name); if (this.redisSubscriber) { await this.redisSubscriber.unsubscribe(`${this.name}:cancel`); await this.redisSubscriber.unsubscribe(`${this.name}:release`); await this.redisSubscriber.unsubscribe(`${this.name}:destroy`); await this.redisSubscriber.disconnect(); this.redisSubscriber = undefined; } await this.redisClient.publish(`${this.name}:destroy`, "destroyed"); this.rejectQueuedAcquirers(new locking_1.CancelledLockingError(message ?? "Semaphore destroyed")); this.resolveUnlockWaiters(); } async freeCount() { this.ensureAlive(); await this.redisClient.zRemRangeByScore(this.name, "-inf", Date.now()); const currentCount = await this.redisClient.zCard(this.name); return this.maxCount - currentCount; } async acquire(params) { this.ensureAlive(); await this.ensureSubscriber(); // `??` and not `||`: timeoutMs 0 means "fail fast", not "use the default". const timeoutMs = params?.timeoutMs ?? constants_1.DEFAULT_TTL_MS; // Lock member expiry must stay positive even when the wait timeout is 0. const lockTtlMs = timeoutMs > 0 ? timeoutMs : constants_1.DEFAULT_TTL_MS; const acquireToken = await this.tryAcquire(lockTtlMs); if (acquireToken) { return new distributed_releaser_1.DistributedReleaser(() => this.release(acquireToken), acquireToken); } if (timeoutMs === 0) { throw new locking_1.TimeoutLockingError("Timeout acquiring"); } return new Promise((resolve, reject) => { const deferred = { resolve, reject, ttlMs: lockTtlMs, timer: null, }; deferred.timer = setTimeout(() => { const index = this.queue.indexOf(deferred); if (index !== -1) { this.queue.splice(index, 1); } reject(new locking_1.TimeoutLockingError("Timeout acquiring")); }, timeoutMs); deferred.timer.unref(); this.queue.push(deferred); }); } async cancelAll(errMessage) { this.ensureAlive(); const msg = `cancel:${errMessage ?? "Semaphore cancelled"}`; await this.redisClient.publish(`${this.name}:cancel`, msg); } async isLocked() { this.ensureAlive(); const free = await this.freeCount(); return free === 0; } async runExclusive(...args) { let callback; let params; if (args.length === 1) { callback = args[0]; } else { params = args[0]; callback = args[1]; } const releaser = await this.acquire(params); try { return await callback(); } finally { await releaser.release(); } } async tryAcquire(ttlMs) { const token = `${this.name}:${node_crypto_1.default.randomUUID()}`; const now = Date.now(); const expiryTimestamp = now + ttlMs; const result = await ACQUIRE_SCRIPT.run(this.redisClient, { keys: [this.name], arguments: [ now.toString(), this.maxCount.toString(), expiryTimestamp.toString(), token, (ttlMs * 3).toString(), ], }); return result === 1 ? token : undefined; } async release(token) { if (this.destroyed) { return; } const removed = await RELEASE_SCRIPT.run(this.redisClient, { keys: [this.name], arguments: [token], }); if (removed === 1) { await this.redisClient.publish(`${this.name}:release`, token); } } ensureAlive() { if (this.destroyed) { throw new locking_1.LockNotFoundError(`${locking_1.ELockDisplayType.Semaphore} '${this.name}' does not exist`); } } } exports.RedisDistributedSemaphore = RedisDistributedSemaphore; //# sourceMappingURL=redis-distributed-semaphore.js.map