@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
JavaScript
"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