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