@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.
159 lines (157 loc) • 6.08 kB
JavaScript
"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 constants_1 = require("./constants");
const node_crypto_1 = __importDefault(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;
node_assert_1.default.ok(maxCount > 0, "maxCount must be greater than 0");
this.maxCount = maxCount;
}
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}:${node_crypto_1.default.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