@solid/community-server
Version:
Community Solid Server: an open and modular implementation of the Solid specifications
193 lines • 8.8 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.RedisLocker = void 0;
const ioredis_1 = __importDefault(require("ioredis"));
const global_logger_factory_1 = require("global-logger-factory");
const LockUtils_1 = require("../LockUtils");
const RedisLuaScripts_1 = require("./scripts/RedisLuaScripts");
const attemptDefaults = { retryCount: -1, retryDelay: 50, retryJitter: 30 };
// Internal prefix for Redis keys;
const PREFIX_RW = '__RW__';
const PREFIX_LOCK = '__L__';
/**
* A Redis Locker that can be used as both:
* * a Read Write Locker that uses a (single) Redis server to store the locks and counts.
* * a Resource Locker that uses a (single) Redis server to store the lock.
* This solution should be process-safe. The only references to locks are string keys
* derived from identifier paths.
*
* The Read Write algorithm roughly goes as follows:
* * Acquire a read lock: allowed as long as there is no write lock. On acquiring the read counter goes up.
* * Acquire a write lock: allowed as long as there is no other write lock AND the read counter is 0.
* * Release a read lock: decreases the read counter with 1
* * Release a write lock: unlocks the write lock
*
* The Resource locking algorithm uses a single mutex/lock.
*
* All operations, such as checking for a write lock AND read count, are executed in a single Lua script.
* These scripts are used by Redis as a single new command.
* Redis executes its operations in a single thread, as such, each such operation can be considered atomic.
*
* The operation to (un)lock will always resolve with either 1/OK/true if succeeded or 0/false if not succeeded.
* Rejection with errors will be happen on actual failures. Retrying the (un)lock operations will be done by making
* use of the LockUtils' {@link retryFunctionUntil} function.
*
* * @see [Redis Commands documentation](https://redis.io/commands/)
* * @see [Redis Lua scripting documentation](https://redis.io/docs/manual/programmability/)
* * @see [ioredis Lua scripting API](https://github.com/luin/ioredis#lua-scripting)
*/
class RedisLocker {
logger = (0, global_logger_factory_1.getLoggerFor)(this);
redis;
redisRw;
redisLock;
attemptSettings;
namespacePrefix;
finalized = false;
/**
* Creates a new RedisClient
*
* @param redisClient - Redis connection string of a standalone Redis node
* @param attemptSettings - Override default AttemptSettings
* @param redisSettings - Addition settings used to create the Redis client or to interact with the Redis server
*/
constructor(redisClient = '127.0.0.1:6379', attemptSettings = {}, redisSettings) {
redisSettings = { namespacePrefix: '', ...redisSettings };
const { namespacePrefix, ...options } = redisSettings;
this.redis = this.createRedisClient(redisClient, options);
this.attemptSettings = { ...attemptDefaults, ...attemptSettings };
this.namespacePrefix = namespacePrefix;
// Register lua scripts
for (const [name, script] of Object.entries(RedisLuaScripts_1.REDIS_LUA_SCRIPTS)) {
this.redis.defineCommand(name, { numberOfKeys: 1, lua: script });
}
this.redisRw = this.redis;
this.redisLock = this.redis;
}
/**
* Generate and return a RedisClient based on the provided string
*
* @param redisClientString - A string that contains either a host address and a
* port number like '127.0.0.1:6379' or just a port number like '6379'.
*/
createRedisClient(redisClientString, options) {
if (redisClientString.length > 0) {
// Check if port number or ip with port number
// Definitely not perfect, but configuring this is only for experienced users
const match = /^(?:([^:]+):)?(\d{4,5})$/u.exec(redisClientString);
if (!match || !match[2]) {
// At least a port number should be provided
throw new Error(`Invalid data provided to create a Redis client: ${redisClientString}\n
Please provide a port number like '6379' or a host address and a port number like '127.0.0.1:6379'`);
}
const port = Number(match[2]);
const host = match[1];
return new ioredis_1.default(port, host, options);
}
throw new Error(`Empty redisClientString provided!\n
Please provide a port number like '6379' or a host address and a port number like '127.0.0.1:6379'`);
}
/**
* Create a scoped Redis key for Read-Write locking.
*
* @param identifier - The identifier object to create a Redis key for
*
* @returns A scoped Redis key that allows cleanup afterwards without affecting other keys.
*/
getReadWriteKey(identifier) {
return `${this.namespacePrefix}${PREFIX_RW}${identifier.path}`;
}
/**
* Create a scoped Redis key for Resource locking.
*
* @param identifier - The identifier object to create a Redis key for
*
* @returns A scoped Redis key that allows cleanup afterwards without affecting other keys.
*/
getResourceKey(identifier) {
return `${this.namespacePrefix}${PREFIX_LOCK}${identifier.path}`;
}
/* ReadWriteLocker methods */
/**
* Wrapper function for all (un)lock operations. If the `fn()` resolves to false (after applying
* {@link fromResp2ToBool}, the result will be swallowed. When `fn()` resolves to true, this wrapper
* will return true. Any error coming from `fn()` will be thrown.
*
* @param fn - The function reference to swallow false from.
*/
swallowFalse(fn) {
if (this.finalized) {
throw new Error('Invalid state: cannot execute Redis operation once finalize() has been called.');
}
return async () => {
const result = await (0, RedisLuaScripts_1.fromResp2ToBool)(fn());
// Swallow any result resolving to `false`
if (result) {
return true;
}
};
}
async withReadLock(identifier, whileLocked) {
const key = this.getReadWriteKey(identifier);
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisRw.acquireReadLock.bind(this.redisRw, key)), this.attemptSettings);
try {
return await whileLocked();
}
finally {
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisRw.releaseReadLock.bind(this.redisRw, key)), this.attemptSettings);
}
}
async withWriteLock(identifier, whileLocked) {
const key = this.getReadWriteKey(identifier);
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisRw.acquireWriteLock.bind(this.redisRw, key)), this.attemptSettings);
try {
return await whileLocked();
}
finally {
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisRw.releaseWriteLock.bind(this.redisRw, key)), this.attemptSettings);
}
}
/* ResourceLocker methods */
async acquire(identifier) {
const key = this.getResourceKey(identifier);
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisLock.acquireLock.bind(this.redisLock, key)), this.attemptSettings);
}
async release(identifier) {
const key = this.getResourceKey(identifier);
await (0, LockUtils_1.retryFunction)(this.swallowFalse(this.redisLock.releaseLock.bind(this.redisLock, key)), this.attemptSettings);
}
/* Initializer & Finalizer methods */
async initialize() {
// On server start: remove all existing (dangling) locks, so new requests are not blocked.
return this.clearLocks();
}
async finalize() {
this.finalized = true;
try {
// On controlled server shutdown: clean up all existing locks.
return await this.clearLocks();
}
finally {
// Always quit the redis client
await this.redis.quit();
}
}
/**
* Remove any lock still open
*/
async clearLocks() {
const keysRw = await this.redisRw.keys(`${this.namespacePrefix}${PREFIX_RW}*`);
if (keysRw.length > 0) {
await this.redisRw.del(...keysRw);
}
const keysLock = await this.redisLock.keys(`${this.namespacePrefix}${PREFIX_LOCK}*`);
if (keysLock.length > 0) {
await this.redisLock.del(...keysLock);
}
}
}
exports.RedisLocker = RedisLocker;
//# sourceMappingURL=RedisLocker.js.map