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.

197 lines (195 loc) 7.47 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RedisDistributedSemaphore = void 0; const assert = require("node:assert"); const constants_1 = require("./constants"); const crypto = require("node:crypto"); const distributed_releaser_1 = require("./distributed-releaser"); const base_distributed_primitive_1 = require("./base-distributed-primitive"); class RedisDistributedSemaphore extends base_distributed_primitive_1.BaseDistributedPrimitive { constructor(props) { super({ ...props, name: `semaphore:${props.name}` }); const { maxCount } = props; assert.ok(maxCount > 0, "maxCount must be greater than 0"); this.maxCount = maxCount; } async waitForAnyUnlock() { this.throwIfDestroyed(); const freeCount = await this.freeCount(); if (freeCount > 0) { return; } await this.ensureSubscriber(); return new Promise((resolve) => { const handler = async () => { // Check if we now have any free slots const currentFreeCount = await this.freeCount(); if (currentFreeCount > 0) { this._redisSubscriber?.unsubscribe(`${this.name}:release`); resolve(); } // If not, keep listening for more release events }; this._redisSubscriber?.subscribe(`${this.name}:release`, handler); }); } async waitForFullyUnlock() { this.throwIfDestroyed(); // Check if already fully unlocked const freeCount = await this.freeCount(); if (freeCount === this.maxCount) { return; } await this.ensureSubscriber(); return new Promise((resolve) => { const handler = async () => { // Check if we're now fully unlocked const currentFreeCount = await this.freeCount(); if (currentFreeCount === this.maxCount) { this._redisSubscriber?.unsubscribe(`${this.name}:release`); resolve(); } // If not, keep listening for more release events }; this._redisSubscriber?.subscribe(`${this.name}:release`, handler); }); } async destroy() { if (this._isDestroyed) { return; } this._isDestroyed = 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"); while (this._queue.length > 0) { const deferred = this._queue.shift(); if (deferred.timer) { clearTimeout(deferred.timer); deferred.timer = null; } deferred.reject(new Error("Semaphore destroyed")); } } async freeCount() { 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.throwIfDestroyed(); await this.ensureSubscriber(); const { timeoutMs = constants_1.DEFAULT_TTL_MS } = params ?? {}; const acquireToken = await this.tryAcquire(timeoutMs); if (acquireToken) { return new distributed_releaser_1.DistributedReleaser(() => this.release(acquireToken), acquireToken); } // Return a promise that resolves once the lock is eventually acquired. return new Promise((resolve, reject) => { const deferred = { resolve, reject, ttlMs: timeoutMs, timer: null }; deferred.timer = setTimeout(() => { const index = this._queue.indexOf(deferred); if (index !== -1) { this._queue.splice(index, 1); } reject(new Error("Timeout acquiring")); }, timeoutMs); this._queue.push(deferred); }); } async release(token) { this.throwIfDestroyed(); const RELEASE_LUA = ` local removed = redis.call('zrem', KEYS[1], ARGV[1]) return removed `; const removed = await this._redisClient.eval(RELEASE_LUA, { keys: [this.name], arguments: [token] }); // Only publish release message if a token was actually removed if (removed === 1) { await this._redisClient.publish(`${this.name}:release`, token); } } async cancelAll(errMessage) { this.throwIfDestroyed(); const msg = `cancel:${errMessage ?? ""}`; await this._redisClient.publish(`${this.name}:cancel`, msg); } async isLocked() { const free = await this.freeCount(); return free === 0; } async runExclusive(...args) { let params; let fn; if (args.length === 1) { fn = args[0]; } else if (args.length === 2) { params = args[0]; fn = args[1]; } else { throw new Error("Invalid arguments for runExclusive"); } const releaser = await this.acquire(params); try { return await fn(); } finally { await this.release(releaser.getToken()); } } async tryAcquire(ttlMs) { const ACQUIRE_LUA = ` -- 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 `; const token = `${this.name}:${crypto.randomUUID()}`; const now = Date.now(); const expiryTimestamp = now + ttlMs; const result = await this._redisClient.eval(ACQUIRE_LUA, { keys: [this.name], arguments: [ now.toString(), // Current time for expiry check this.maxCount.toString(), // Max count of semaphore expiryTimestamp.toString(), // Expiry timestamp for the new lock token, // Token for the lock (ttlMs * 3).toString() // TTL for the key in Redis ] }); return result === 1 ? token : undefined; } throwIfDestroyed() { if (this._isDestroyed) { throw new Error("Semaphore has been destroyed"); } } } exports.RedisDistributedSemaphore = RedisDistributedSemaphore; //# sourceMappingURL=redis-distributed-semaphore.js.map