@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.
150 lines • 5.91 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.BaseDistributedLockPrimitive = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const locking_1 = require("@apiratorjs/locking");
const distributed_releaser_1 = require("./distributed-releaser");
class BaseDistributedLockPrimitive {
constructor(props) {
this.implementation = "redis";
this.unlockWaiters = new Set();
this.destroyed = false;
const { name, redisClient } = props;
node_assert_1.default.ok(name, "name must be provided");
this.name = name;
this.redisClient = redisClient;
this.queue = [];
}
get isDestroyed() {
return this.destroyed;
}
async ensureSubscriber() {
if (this.redisSubscriber) {
return;
}
this.redisSubscriber = this.redisClient.duplicate();
await this.redisSubscriber.connect();
await this.redisSubscriber.subscribe(`${this.name}:cancel`, (message) => {
if (!message.startsWith("cancel:")) {
return;
}
const errMessage = message.slice("cancel:".length);
this.rejectQueuedAcquirers(new locking_1.CancelledLockingError(errMessage || "Cancelled"));
// Unlock waiters keep waiting: cancel only drops pending acquisitions.
// Held locks stay held, so waitForUnlock* is still meaningful.
});
await this.redisSubscriber.subscribe(`${this.name}:release`, async () => {
while (this.queue.length > 0) {
const nextInQueue = this.queue.shift();
const acquireToken = await this.tryAcquire(nextInQueue.ttlMs);
if (!acquireToken) {
this.queue.unshift(nextInQueue);
break;
}
if (nextInQueue.timer) {
clearTimeout(nextInQueue.timer);
nextInQueue.timer = null;
}
const releaser = new distributed_releaser_1.DistributedReleaser(() => this.release(acquireToken), acquireToken);
nextInQueue.resolve(releaser);
}
// Queued acquirers get first refusal; only what is left over frees up
// the waiters of waitForUnlock / waitForAnyUnlock / waitForFullyUnlock.
await this.notifyUnlockWaiters();
});
await this.redisSubscriber.subscribe(`${this.name}:destroy`, async () => {
await this.destroy();
});
}
/**
* Waits until `isSatisfied` holds after a release event.
*
* Waiters are kept locally and driven by the single `:release` subscription
* opened in `ensureSubscriber`. They must never subscribe to that channel
* themselves: node-redis appends listeners per `subscribe` call, and
* `unsubscribe(channel)` without a listener drops *all* of them - including
* the one that drains `queue`.
*/
async waitForUnlockEvent(isSatisfied) {
if (await isSatisfied()) {
return;
}
await this.ensureSubscriber();
return new Promise((resolve, reject) => {
const waiter = {
isSatisfied,
resolve: () => {
this.unlockWaiters.delete(waiter);
resolve();
},
reject: (error) => {
this.unlockWaiters.delete(waiter);
reject(error);
},
};
this.unlockWaiters.add(waiter);
// A release may have landed while the subscriber was being set up.
isSatisfied().then((satisfied) => {
if (satisfied) {
waiter.resolve();
}
}, (error) => {
// destroy() flips `destroyed` before settling waiters; isLocked /
// freeCount then throw. Prefer resolve so we don't race ahead of
// resolveUnlockWaiters() and leave waitForUnlock* rejected.
if (this.destroyed) {
waiter.resolve();
return;
}
waiter.reject(error);
});
});
}
async notifyUnlockWaiters() {
if (this.destroyed) {
// In-flight `:release` handling can outlive destroy()'s unsubscribe.
// Satisfaction checks throw once destroyed; settle the same way destroy does.
this.resolveUnlockWaiters();
return;
}
for (const waiter of [...this.unlockWaiters]) {
try {
if (await waiter.isSatisfied()) {
waiter.resolve();
}
}
catch (error) {
if (this.destroyed) {
waiter.resolve();
continue;
}
waiter.reject(error);
}
}
}
rejectQueuedAcquirers(error) {
while (this.queue.length > 0) {
const deferred = this.queue.shift();
if (deferred.timer) {
clearTimeout(deferred.timer);
deferred.timer = null;
}
deferred.reject(error);
}
}
/**
* A destroyed lock cannot be held: settle unlock waiters successfully so a
* fire-and-forget `void lock.waitForUnlock()` does not become an unhandled
* rejection.
*/
resolveUnlockWaiters() {
for (const waiter of [...this.unlockWaiters]) {
waiter.resolve();
}
}
}
exports.BaseDistributedLockPrimitive = BaseDistributedLockPrimitive;
//# sourceMappingURL=base-distributed-lock-primitive.js.map