mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add redis based locking mechanism
* feat: redis based locking mechanism * fix: adapted to review and wrote unit tests * fix: adapted to review(Woutermont) * fix: adapted to review and expanded tests * test: redlock integration tests * test: corrected file name * test: tests should run on CI now * test: improved tests and minor changes according to review * fix: forgot describeIf docker * test: adapted to review * test: Mock all redis dependencies Co-authored-by: Joachim Van Herwegen <joachimvh@gmail.com>
This commit is contained in:
parent
953458231b
commit
99d0173213
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -41,6 +41,10 @@ jobs:
|
|||||||
SPARQL_UPDATE: true
|
SPARQL_UPDATE: true
|
||||||
ports:
|
ports:
|
||||||
- 4000:8890
|
- 4000:8890
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
steps:
|
steps:
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
|
68
package-lock.json
generated
68
package-lock.json
generated
@ -1152,6 +1152,11 @@
|
|||||||
"@babel/types": "^7.3.0"
|
"@babel/types": "^7.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/bluebird": {
|
||||||
|
"version": "3.5.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.33.tgz",
|
||||||
|
"integrity": "sha512-ndEo1xvnYeHxm7I/5sF6tBvnsA4Tdi3zj1keRKRs12SP+2ye2A27NDJ1B6PqkfMbGAcT+mqQVqbZRIrhfOp5PQ=="
|
||||||
|
},
|
||||||
"@types/cookiejar": {
|
"@types/cookiejar": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||||
@ -1324,6 +1329,22 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/redis": {
|
||||||
|
"version": "2.8.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.28.tgz",
|
||||||
|
"integrity": "sha512-8l2gr2OQ969ypa7hFOeKqtFoY70XkHxISV0pAwmQ2nm6CSPb1brmTmqJCGGrekCo+pAZyWlNXr+Kvo6L/1wijA==",
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/redlock": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/redlock/-/redlock-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-YrPEPHOgLlWtsg/Ocv/gthGDQpx3HXFPhBvCNuBKBKUoMBzvCjCTC95dmJ0WLZgO+fgTxGQFyk6ZO/+/zG5mRg==",
|
||||||
|
"requires": {
|
||||||
|
"@types/bluebird": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/rimraf": {
|
"@types/rimraf": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.0.tgz",
|
||||||
@ -2084,6 +2105,11 @@
|
|||||||
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"bluebird": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||||
|
},
|
||||||
"boxen": {
|
"boxen": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz",
|
||||||
@ -3029,6 +3055,11 @@
|
|||||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
|
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"denque": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
|
||||||
|
},
|
||||||
"depd": {
|
"depd": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||||
@ -8082,6 +8113,43 @@
|
|||||||
"strip-indent": "^3.0.0"
|
"strip-indent": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"redis": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==",
|
||||||
|
"requires": {
|
||||||
|
"denque": "^1.4.1",
|
||||||
|
"redis-commands": "^1.5.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"redis-commands": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
|
||||||
|
},
|
||||||
|
"redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
|
||||||
|
},
|
||||||
|
"redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
|
||||||
|
"requires": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"redlock": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redlock/-/redlock-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==",
|
||||||
|
"requires": {
|
||||||
|
"bluebird": "^3.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"regex-not": {
|
"regex-not": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
|
||||||
|
@ -86,6 +86,8 @@
|
|||||||
"@types/pump": "^1.1.0",
|
"@types/pump": "^1.1.0",
|
||||||
"@types/punycode": "^2.1.0",
|
"@types/punycode": "^2.1.0",
|
||||||
"@types/rdf-js": "^4.0.0",
|
"@types/rdf-js": "^4.0.0",
|
||||||
|
"@types/redis": "^2.8.28",
|
||||||
|
"@types/redlock": "^4.0.1",
|
||||||
"@types/sparqljs": "^3.1.0",
|
"@types/sparqljs": "^3.1.0",
|
||||||
"@types/streamify-array": "^1.0.0",
|
"@types/streamify-array": "^1.0.0",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
@ -106,6 +108,8 @@
|
|||||||
"rdf-parse": "^1.7.0",
|
"rdf-parse": "^1.7.0",
|
||||||
"rdf-serialize": "^1.1.0",
|
"rdf-serialize": "^1.1.0",
|
||||||
"rdf-terms": "^1.5.1",
|
"rdf-terms": "^1.5.1",
|
||||||
|
"redis": "^3.0.2",
|
||||||
|
"redlock": "^4.2.0",
|
||||||
"sparqlalgebrajs": "^2.5.1",
|
"sparqlalgebrajs": "^2.5.1",
|
||||||
"sparqljs": "^3.1.2",
|
"sparqljs": "^3.1.2",
|
||||||
"streamify-array": "^1.0.1",
|
"streamify-array": "^1.0.1",
|
||||||
|
@ -243,6 +243,7 @@ export * from './util/locking/ExpiringReadWriteLocker';
|
|||||||
export * from './util/locking/EqualReadWriteLocker';
|
export * from './util/locking/EqualReadWriteLocker';
|
||||||
export * from './util/locking/GreedyReadWriteLocker';
|
export * from './util/locking/GreedyReadWriteLocker';
|
||||||
export * from './util/locking/ReadWriteLocker';
|
export * from './util/locking/ReadWriteLocker';
|
||||||
|
export * from './util/locking/RedisResourceLocker';
|
||||||
export * from './util/locking/ResourceLocker';
|
export * from './util/locking/ResourceLocker';
|
||||||
export * from './util/locking/SingleThreadedResourceLocker';
|
export * from './util/locking/SingleThreadedResourceLocker';
|
||||||
export * from './util/locking/WrappedExpiringReadWriteLocker';
|
export * from './util/locking/WrappedExpiringReadWriteLocker';
|
||||||
|
174
src/util/locking/RedisResourceLocker.ts
Normal file
174
src/util/locking/RedisResourceLocker.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { assert } from 'console';
|
||||||
|
import type { RedisClient } from 'redis';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
import type { Lock } from 'redlock';
|
||||||
|
import Redlock from 'redlock';
|
||||||
|
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
|
||||||
|
import { getLoggerFor } from '../../logging/LogUtil';
|
||||||
|
import { InternalServerError } from '../errors/InternalServerError';
|
||||||
|
import type { ResourceLocker } from './ResourceLocker';
|
||||||
|
|
||||||
|
// The ttl set on a lock, not really important cause redlock wil not handle expiration
|
||||||
|
const ttl = 10000;
|
||||||
|
// The default redlock config
|
||||||
|
const defaultRedlockConfig = {
|
||||||
|
// The expected clock drift; for more details
|
||||||
|
// see http://redis.io/topics/distlock
|
||||||
|
// Multiplied by lock ttl to determine drift time
|
||||||
|
driftFactor: 0.01,
|
||||||
|
// The max number of times Redlock will attempt
|
||||||
|
// to lock a resource before erroring
|
||||||
|
retryCount: 1000000,
|
||||||
|
// The time in ms between attempts
|
||||||
|
retryDelay: 200,
|
||||||
|
// The max time in ms randomly added to retries
|
||||||
|
// to improve performance under high contention
|
||||||
|
// see https://www.awsarchitectureblog.com/2015/03/backoff.html
|
||||||
|
retryJitter: 200,
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* A locking system that uses a Redis server or any number of
|
||||||
|
* Redis nodes / clusters
|
||||||
|
* This solution has issues though:
|
||||||
|
* - Redlock wants to handle expiration itself, this is against the design of a ResourceLocker.
|
||||||
|
* The workaround for this is to extend an active lock indefinitely.
|
||||||
|
* - This solution is not multithreaded! If threadA locks a resource, only threadA can unlock this resource.
|
||||||
|
* If threadB wont be able to lock a resource if threadA already acquired that lock,
|
||||||
|
* in that sense it is kind of multithreaded.
|
||||||
|
* - Redlock does not provide the ability to see which locks have expired
|
||||||
|
*/
|
||||||
|
export class RedisResourceLocker implements ResourceLocker {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly redlock: Redlock;
|
||||||
|
private readonly lockMap: Map<string, { lock: Lock; interval: NodeJS.Timeout }>;
|
||||||
|
|
||||||
|
public constructor(redisClients: string[], redlockOptions?: Record<string, number>) {
|
||||||
|
this.lockMap = new Map();
|
||||||
|
const clients = this.createRedisClients(redisClients);
|
||||||
|
if (clients.length === 0) {
|
||||||
|
throw new Error('At least 1 client should be provided');
|
||||||
|
}
|
||||||
|
this.redlock = this.createRedlock(clients, redlockOptions);
|
||||||
|
this.redlock.on('clientError', (err): void => {
|
||||||
|
this.logger.error(`Redis/Redlock error: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and return a list of RedisClients based on the provided strings
|
||||||
|
* @param redisClientsStrings - a list of strings that contain either a host address and a
|
||||||
|
* port number like '127.0.0.1:6379' or just a port number like '6379'
|
||||||
|
*/
|
||||||
|
private createRedisClients(redisClientsStrings: string[]): RedisClient[] {
|
||||||
|
const result: RedisClient[] = [];
|
||||||
|
if (redisClientsStrings && redisClientsStrings.length > 0) {
|
||||||
|
for (const client of redisClientsStrings) {
|
||||||
|
// Check if port number or ip with port number
|
||||||
|
// Definitely not perfect, but configuring this is only for experienced users
|
||||||
|
const match = new RegExp(/^(?:([^:]+):)?(\d{4,5})$/u, 'u').exec(client);
|
||||||
|
if (!match || !match[2]) {
|
||||||
|
// At least a port number should be provided
|
||||||
|
throw new Error(`Invalid data provided to create a Redis client: ${client}\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];
|
||||||
|
const redisclient = createClient(port, host);
|
||||||
|
result.push(redisclient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate and return a Redlock instance
|
||||||
|
* @param clients - a list of RedisClients you want to use for the redlock instance
|
||||||
|
* @param redlockOptions - extra redlock options to overwrite the default config
|
||||||
|
*/
|
||||||
|
private createRedlock(clients: RedisClient[], redlockOptions: Record<string, number> = {}): Redlock {
|
||||||
|
try {
|
||||||
|
return new Redlock(
|
||||||
|
clients,
|
||||||
|
{ ...defaultRedlockConfig, ...redlockOptions },
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new InternalServerError(`Error initializing Redlock: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async quit(): Promise<void> {
|
||||||
|
// This for loop is an extra failsafe,
|
||||||
|
// this extra code won't slow down anything, this function will only be called to shut down in peace
|
||||||
|
for (const [ , { lock }] of this.lockMap.entries()) {
|
||||||
|
await this.release({ path: lock.resource });
|
||||||
|
}
|
||||||
|
await this.redlock.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async acquire(identifier: ResourceIdentifier): Promise<void> {
|
||||||
|
const resource = identifier.path;
|
||||||
|
let lock: Lock | undefined;
|
||||||
|
try {
|
||||||
|
lock = await this.redlock.lock(resource, ttl);
|
||||||
|
assert(lock);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.debug(`Unable to acquire lock for ${resource}`);
|
||||||
|
throw new InternalServerError(`Unable to acquire lock for ${resource} (${error})`);
|
||||||
|
}
|
||||||
|
if (this.lockMap.get(resource)) {
|
||||||
|
throw new InternalServerError(`Acquired duplicate lock on ${resource}`);
|
||||||
|
}
|
||||||
|
// Lock acquired
|
||||||
|
this.logger.debug(`Acquired lock for resource: ${resource}!`);
|
||||||
|
const interval = this.extendLockIndefinitely(resource);
|
||||||
|
this.lockMap.set(resource, { lock, interval });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async release(identifier: ResourceIdentifier): Promise<void> {
|
||||||
|
const resource = identifier.path;
|
||||||
|
const entry = this.lockMap.get(resource);
|
||||||
|
if (!entry) {
|
||||||
|
// Lock is invalid
|
||||||
|
this.logger.warn(`Unexpected release request for non-existent lock on ${resource}`);
|
||||||
|
throw new InternalServerError(`Trying to unlock resource that is not locked: ${resource}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.redlock.unlock(entry.lock);
|
||||||
|
clearInterval(entry.interval);
|
||||||
|
this.lockMap.delete(resource);
|
||||||
|
// Successfully released lock
|
||||||
|
this.logger.debug(`Released lock for ${resource}, ${this.getLockCount()} active locks remaining!`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error(`Error releasing lock for ${resource} (${error})`);
|
||||||
|
throw new InternalServerError(`Unable to release lock for: ${resource}, ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the number of active locks.
|
||||||
|
*/
|
||||||
|
private getLockCount(): number {
|
||||||
|
return this.lockMap.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is internally used to keep an acquired lock active, a wrapper class will handle expiration
|
||||||
|
* @param identifier - Identifier of the lock to be extended
|
||||||
|
*/
|
||||||
|
private extendLockIndefinitely(identifier: string): NodeJS.Timeout {
|
||||||
|
return setInterval(async(): Promise<void> => {
|
||||||
|
const entry = this.lockMap.get(identifier)!;
|
||||||
|
try {
|
||||||
|
const newLock = await this.redlock.extend(entry.lock, ttl);
|
||||||
|
this.lockMap.set(identifier, { lock: newLock, interval: entry.interval });
|
||||||
|
this.logger.debug(`Extended (Redis)lock for resource: ${identifier}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// No error should be re-thrown because this means the lock has simply been released
|
||||||
|
this.logger.error(`Failed to extend this (Redis)lock for resource: ${identifier}, ${error}`);
|
||||||
|
clearInterval(entry.interval);
|
||||||
|
this.lockMap.delete(identifier);
|
||||||
|
}
|
||||||
|
}, ttl / 2);
|
||||||
|
}
|
||||||
|
}
|
173
test/integration/RedisResourceLockerIntegration.test.ts
Normal file
173
test/integration/RedisResourceLockerIntegration.test.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import type { Server } from 'http';
|
||||||
|
import fetch from 'cross-fetch';
|
||||||
|
import type { RedisResourceLocker } from '../../src';
|
||||||
|
import { joinFilePath } from '../../src';
|
||||||
|
import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
|
||||||
|
import { describeIf } from '../util/TestHelpers';
|
||||||
|
import { instantiateFromConfig } from './Config';
|
||||||
|
/**
|
||||||
|
* Test the general functionality of the server using a RedisResourceLocker
|
||||||
|
*/
|
||||||
|
describeIf('docker', 'A server with a RedisResourceLocker as ResourceLocker', (): void => {
|
||||||
|
const port = 6008;
|
||||||
|
const baseUrl = `http://localhost:${port}/`;
|
||||||
|
let server: Server;
|
||||||
|
let locker: RedisResourceLocker;
|
||||||
|
let factory: HttpServerFactory;
|
||||||
|
|
||||||
|
beforeAll(async(): Promise<void> => {
|
||||||
|
const instances = await instantiateFromConfig(
|
||||||
|
'urn:solid-server:test:Instances',
|
||||||
|
'run-with-redlock.json',
|
||||||
|
{
|
||||||
|
'urn:solid-server:default:variable:baseUrl': baseUrl,
|
||||||
|
'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
|
||||||
|
},
|
||||||
|
) as Record<string, any>;
|
||||||
|
({ factory, locker } = instances);
|
||||||
|
server = factory.startServer(port);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async(): Promise<void> => {
|
||||||
|
await locker.quit();
|
||||||
|
await new Promise<void>((resolve, reject): void => {
|
||||||
|
server.close((error): void => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add a file to the store, read it and delete it.', async(): Promise<void> => {
|
||||||
|
// Create file
|
||||||
|
const fileUrl = `${baseUrl}testfile2.txt`;
|
||||||
|
const fileData = 'TESTFILE2';
|
||||||
|
|
||||||
|
let response = await fetch(fileUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/plain',
|
||||||
|
},
|
||||||
|
body: fileData,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
|
||||||
|
// Get file
|
||||||
|
response = await fetch(fileUrl);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.text();
|
||||||
|
expect(body).toContain('TESTFILE2');
|
||||||
|
|
||||||
|
// DELETE file
|
||||||
|
response = await fetch(fileUrl, { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
response = await fetch(fileUrl);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a folder and delete it.', async(): Promise<void> => {
|
||||||
|
const containerPath = 'secondfolder/';
|
||||||
|
const containerUrl = `${baseUrl}${containerPath}`;
|
||||||
|
// PUT
|
||||||
|
let response = await fetch(containerUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
|
||||||
|
// GET
|
||||||
|
response = await fetch(containerUrl);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
response = await fetch(containerUrl, { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
response = await fetch(containerUrl);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can get a resource multiple times.', async(): Promise<void> => {
|
||||||
|
const fileUrl = `${baseUrl}image.png`;
|
||||||
|
const fileData = 'testtesttest';
|
||||||
|
|
||||||
|
let response = await fetch(fileUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/plain',
|
||||||
|
},
|
||||||
|
body: fileData,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
|
||||||
|
// GET 4 times
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const res = await fetch(fileUrl);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toBe('text/plain');
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('testtesttest');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
response = await fetch(fileUrl, { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
response = await fetch(fileUrl);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Test the ResourceLocker itself', (): void => {
|
||||||
|
const identifier = { path: 'http://test.com/foo' };
|
||||||
|
|
||||||
|
it('can lock and unlock a resource.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can lock a resource again after it was unlocked.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can acquire different keys simultaneously.', async(): Promise<void> => {
|
||||||
|
const lock1 = locker.acquire({ path: 'path1' });
|
||||||
|
const lock2 = locker.acquire({ path: 'path2' });
|
||||||
|
const lock3 = locker.acquire({ path: 'path3' });
|
||||||
|
|
||||||
|
await expect(Promise.all([ lock1, lock2, lock3 ])).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await locker.release({ path: 'path1' });
|
||||||
|
await locker.release({ path: 'path2' });
|
||||||
|
await locker.release({ path: 'path3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cannot acquire the same lock simultaneously.', async(): Promise<void> => {
|
||||||
|
let res = '';
|
||||||
|
const lock1 = locker.acquire(identifier);
|
||||||
|
const lock2 = locker.acquire(identifier);
|
||||||
|
const lock3 = locker.acquire(identifier);
|
||||||
|
|
||||||
|
await new Promise((resolve): any => setImmediate(resolve));
|
||||||
|
|
||||||
|
const l2 = lock2.then(async(): Promise<void> => {
|
||||||
|
res += 'l2';
|
||||||
|
await locker.release(identifier);
|
||||||
|
res += 'r2';
|
||||||
|
});
|
||||||
|
const l1 = lock1.then(async(): Promise<void> => {
|
||||||
|
res += 'l1';
|
||||||
|
await locker.release(identifier);
|
||||||
|
res += 'r1';
|
||||||
|
});
|
||||||
|
const l3 = lock3.then(async(): Promise<void> => {
|
||||||
|
res += 'l3';
|
||||||
|
await locker.release(identifier);
|
||||||
|
res += 'r3';
|
||||||
|
});
|
||||||
|
await Promise.all([ l1, l2, l3 ]);
|
||||||
|
expect(res).toContain('l1r1');
|
||||||
|
expect(res).toContain('l2r2');
|
||||||
|
expect(res).toContain('l3r3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
168
test/integration/config/run-with-redlock.json
Normal file
168
test/integration/config/run-with-redlock.json
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld",
|
||||||
|
"import": [
|
||||||
|
"files-scs:config/presets/acl.json",
|
||||||
|
"files-scs:config/presets/http.json",
|
||||||
|
"files-scs:config/presets/identifiers/suffix-identifiers.json",
|
||||||
|
"files-scs:config/presets/ldp/credentials-extractor.json",
|
||||||
|
"files-scs:config/presets/ldp/metadata-handler.json",
|
||||||
|
"files-scs:config/presets/ldp/operation-handler.json",
|
||||||
|
"files-scs:config/presets/ldp/permissions-extractor.json",
|
||||||
|
"files-scs:config/presets/ldp/response-writer.json",
|
||||||
|
"files-scs:config/presets/ldp/request-parser.json",
|
||||||
|
"files-scs:config/presets/ldp/websockets.json",
|
||||||
|
"files-scs:config/presets/middleware.json",
|
||||||
|
"files-scs:config/presets/pod-management.json",
|
||||||
|
"files-scs:config/presets/representation-conversion.json",
|
||||||
|
"files-scs:config/presets/static.json",
|
||||||
|
"files-scs:config/presets/storage/backend/storage-memory.json",
|
||||||
|
"files-scs:config/presets/cli-params.json"
|
||||||
|
],
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:test:Instances",
|
||||||
|
"@type": "RecordObject",
|
||||||
|
"RecordObject:_record": [
|
||||||
|
{
|
||||||
|
"RecordObject:_record_key": "factory",
|
||||||
|
"RecordObject:_record_value": {
|
||||||
|
"@id": "urn:solid-server:default:ServerFactory"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RecordObject:_record_key": "locker",
|
||||||
|
"RecordObject:_record_value": {
|
||||||
|
"@id": "urn:solid-server:default:RedisResourceLocker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:LdpHandler",
|
||||||
|
"@type": "AuthenticatedLdpHandler",
|
||||||
|
"AuthenticatedLdpHandler:_args_requestParser": {
|
||||||
|
"@id": "urn:solid-server:default:RequestParser"
|
||||||
|
},
|
||||||
|
"AuthenticatedLdpHandler:_args_credentialsExtractor": {
|
||||||
|
"@id": "urn:solid-server:default:CredentialsExtractor"
|
||||||
|
},
|
||||||
|
"AuthenticatedLdpHandler:_args_permissionsExtractor": {
|
||||||
|
"@id": "urn:solid-server:default:PermissionsExtractor"
|
||||||
|
},
|
||||||
|
"AuthenticatedLdpHandler:_args_authorizer": {
|
||||||
|
"@type": "AllowEverythingAuthorizer"
|
||||||
|
},
|
||||||
|
"AuthenticatedLdpHandler:_args_operationHandler": {
|
||||||
|
"@id": "urn:solid-server:default:OperationHandler"
|
||||||
|
},
|
||||||
|
"AuthenticatedLdpHandler:_args_responseWriter": {
|
||||||
|
"@id": "urn:solid-server:default:ResponseWriter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:RoutingResourceStore",
|
||||||
|
"@type": "PassthroughStore",
|
||||||
|
"PassthroughStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:MemoryResourceStore"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
|
"@type": "MonitoringStore",
|
||||||
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Locking"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:AuxiliaryStrategy",
|
||||||
|
"@type": "RoutingAuxiliaryStrategy",
|
||||||
|
"RoutingAuxiliaryStrategy:_sources": [
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:AclStrategy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
|
"@type": "LockingResourceStore",
|
||||||
|
"LockingResourceStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Patching"
|
||||||
|
},
|
||||||
|
"LockingResourceStore:_locks": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceLocker"
|
||||||
|
},
|
||||||
|
"LockingResourceStore:_strategy": {
|
||||||
|
"@id": "urn:solid-server:default:AuxiliaryStrategy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
|
"@type": "PatchingStore",
|
||||||
|
"PatchingStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Converting"
|
||||||
|
},
|
||||||
|
"PatchingStore:_patcher": {
|
||||||
|
"@id": "urn:solid-server:default:PatchHandler",
|
||||||
|
"@type": "SparqlUpdatePatchHandler",
|
||||||
|
"SparqlUpdatePatchHandler:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_ToTurtle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceLocker",
|
||||||
|
"@type": "WrappedExpiringReadWriteLocker",
|
||||||
|
"WrappedExpiringReadWriteLocker:_locker": {
|
||||||
|
"@type": "GreedyReadWriteLocker",
|
||||||
|
"GreedyReadWriteLocker:_locker": {
|
||||||
|
"@id": "urn:solid-server:default:RedisResourceLocker",
|
||||||
|
"@type": "RedisResourceLocker",
|
||||||
|
"RedisResourceLocker:_redisClients": [ "6379" ]
|
||||||
|
},
|
||||||
|
"GreedyReadWriteLocker:_storage": {
|
||||||
|
"@type": "ResourceIdentifierStorage",
|
||||||
|
"ResourceIdentifierStorage:_source": {
|
||||||
|
"@type": "MemoryMapStorage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GreedyReadWriteLocker:_suffixes_count": "count",
|
||||||
|
"GreedyReadWriteLocker:_suffixes_read": "read",
|
||||||
|
"GreedyReadWriteLocker:_suffixes_write": "write"
|
||||||
|
},
|
||||||
|
"WrappedExpiringReadWriteLocker:_expiration": 3000
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_ToTurtle",
|
||||||
|
"@type": "RepresentationConvertingStore",
|
||||||
|
"RepresentationConvertingStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:RoutingResourceStore"
|
||||||
|
},
|
||||||
|
"RepresentationConvertingStore:_options_outConverter": {
|
||||||
|
"@id": "urn:solid-server:default:RepresentationConverter"
|
||||||
|
},
|
||||||
|
"RepresentationConvertingStore:_options_inConverter": {
|
||||||
|
"@id": "urn:solid-server:default:RepresentationConverter"
|
||||||
|
},
|
||||||
|
"RepresentationConvertingStore:_options_inType": "text/turtle"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Converting",
|
||||||
|
"@type": "RepresentationConvertingStore",
|
||||||
|
"RepresentationConvertingStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:RoutingResourceStore"
|
||||||
|
},
|
||||||
|
"RepresentationConvertingStore:_options_outConverter": {
|
||||||
|
"@id": "urn:solid-server:default:RepresentationConverter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
208
test/unit/util/locking/RedisResourceLocker.test.ts
Normal file
208
test/unit/util/locking/RedisResourceLocker.test.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
// eslint-disable-next-line import/default
|
||||||
|
import redis from 'redis';
|
||||||
|
import Redlock from 'redlock';
|
||||||
|
import type { Lock } from 'redlock';
|
||||||
|
import * as LogUtil from '../../../../src/logging/LogUtil';
|
||||||
|
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
|
||||||
|
import { RedisResourceLocker } from '../../../../src/util/locking/RedisResourceLocker';
|
||||||
|
|
||||||
|
const redlock: jest.Mocked<Redlock> = Object.assign(new EventEmitter(), {
|
||||||
|
lock: jest.fn().mockImplementation(async(resource: string, ttl: number): Promise<Lock> =>
|
||||||
|
({ resource, expiration: Date.now() + ttl } as Lock)),
|
||||||
|
unlock: jest.fn(),
|
||||||
|
extend: jest.fn().mockImplementation(
|
||||||
|
async(lock: Lock, ttl: number): Promise<Lock> => {
|
||||||
|
lock.expiration += ttl;
|
||||||
|
return lock;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
quit: jest.fn(),
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
jest.mock('redlock', (): any => jest.fn().mockImplementation((): Redlock => redlock));
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
describe('A RedisResourceLocker', (): void => {
|
||||||
|
let locker: RedisResourceLocker;
|
||||||
|
const identifier = { path: 'http://test.com/foo' };
|
||||||
|
let createClient: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
redlock.removeAllListeners();
|
||||||
|
|
||||||
|
createClient = jest.spyOn(redis, 'createClient').mockImplementation(jest.fn());
|
||||||
|
|
||||||
|
locker = new RedisResourceLocker([ '6379' ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async(): Promise<void> => {
|
||||||
|
// In case some locks are not released by a test the timers will still be running
|
||||||
|
jest.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async(): Promise<void> => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can lock and unlock a resource.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
expect(redlock.lock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redlock.unlock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can lock a resource again after it was unlocked.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
expect(redlock.lock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(redlock.unlock).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when unlocking a resource that was not locked.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
await expect(locker.release(identifier)).rejects.toThrow(InternalServerError);
|
||||||
|
expect(redlock.lock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redlock.unlock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when redlock.lock throws an error.', async(): Promise<void> => {
|
||||||
|
redlock.lock.mockRejectedValueOnce(new Error('random Error'));
|
||||||
|
const prom = locker.acquire(identifier);
|
||||||
|
await expect(prom).rejects.toThrow(InternalServerError);
|
||||||
|
await expect(prom).rejects.toThrow('Unable to acquire lock for ');
|
||||||
|
await expect(prom).rejects.toThrow('Error: random Error');
|
||||||
|
expect(redlock.lock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if redlock.lock resolves but a lock is already stored.', async(): Promise<void> => {
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
// Works since redlock.lock is mocked to always succeed
|
||||||
|
const prom = locker.acquire(identifier);
|
||||||
|
await expect(prom).rejects.toThrow(InternalServerError);
|
||||||
|
await expect(prom).rejects.toThrow(`Acquired duplicate lock on ${identifier.path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when redlock.unlock throws an error.', async(): Promise<void> => {
|
||||||
|
await locker.acquire(identifier);
|
||||||
|
redlock.unlock.mockRejectedValueOnce(new Error('random Error'));
|
||||||
|
const prom = locker.release(identifier);
|
||||||
|
await expect(prom).rejects.toThrow(InternalServerError);
|
||||||
|
await expect(prom).rejects.toThrow('Unable to release lock for: ');
|
||||||
|
await expect(prom).rejects.toThrow('Error: random Error');
|
||||||
|
expect(redlock.unlock).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not extend when there are no locks to extend.', async(): Promise<void> => {
|
||||||
|
await locker.acquire(identifier);
|
||||||
|
await locker.release(identifier);
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
expect(redlock.extend).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up if lock extension failed.', async(): Promise<void> => {
|
||||||
|
// This should never happen though
|
||||||
|
redlock.extend.mockImplementationOnce((): any => {
|
||||||
|
throw new Error('random error');
|
||||||
|
});
|
||||||
|
await locker.acquire(identifier);
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
expect(redlock.extend).toHaveBeenCalledTimes(1);
|
||||||
|
// Will throw since we removed the lock entry
|
||||||
|
await expect(locker.release(identifier)).rejects.toThrow(InternalServerError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can acquire different keys simultaneously.', async(): Promise<void> => {
|
||||||
|
const lock1 = locker.acquire({ path: 'path1' });
|
||||||
|
const lock2 = locker.acquire({ path: 'path2' });
|
||||||
|
const lock3 = locker.acquire({ path: 'path3' });
|
||||||
|
|
||||||
|
await expect(Promise.all([ lock1, lock2, lock3 ])).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await locker.release({ path: 'path1' });
|
||||||
|
await locker.release({ path: 'path2' });
|
||||||
|
await locker.release({ path: 'path3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends a lock indefinitely.', async(): Promise<void> => {
|
||||||
|
await locker.acquire(identifier);
|
||||||
|
jest.advanceTimersByTime(20000);
|
||||||
|
await expect(locker.release(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses users redlockOptions if passed to constructor.', async(): Promise<void> => {
|
||||||
|
// Reset calls done in `beforeEach`
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const clients = [ '6379' ];
|
||||||
|
const options = {
|
||||||
|
driftFactor: 0.2,
|
||||||
|
retryCount: 20,
|
||||||
|
retryDelay: 2000,
|
||||||
|
retryJitter: 2000,
|
||||||
|
};
|
||||||
|
locker = new RedisResourceLocker(clients, options);
|
||||||
|
expect(Redlock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Redlock).toHaveBeenLastCalledWith(expect.any(Array), options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on creation when no redis servers are passed to the constructor.', async(): Promise<void> => {
|
||||||
|
expect((): any => new RedisResourceLocker([])).toThrow('At least 1 client should be provided');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if there is an issue creating the Redlock.', async(): Promise<void> => {
|
||||||
|
(Redlock as unknown as jest.Mock).mockImplementationOnce((): never => {
|
||||||
|
throw new Error('redlock error!');
|
||||||
|
});
|
||||||
|
expect((): any => new RedisResourceLocker([ '1234' ]))
|
||||||
|
.toThrow('Error initializing Redlock: Error: redlock error!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs redis client errors.', async(): Promise<void> => {
|
||||||
|
const logger = { error: jest.fn() };
|
||||||
|
const mock = jest.spyOn(LogUtil, 'getLoggerFor');
|
||||||
|
mock.mockReturnValueOnce(logger as any);
|
||||||
|
locker = new RedisResourceLocker([ '6379' ]);
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||||
|
redlock.emit('clientError', 'problem!');
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.error).toHaveBeenLastCalledWith('Redis/Redlock error: problem!');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRedisClients', (): void => {
|
||||||
|
it('should create and return the right amount of redisClients.', async(): Promise<void> => {
|
||||||
|
// Reset calls done in `beforeEach`
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const clientStrings = [ '6379', '127.0.0.1:6378' ];
|
||||||
|
locker = new RedisResourceLocker(clientStrings);
|
||||||
|
expect(createClient).toHaveBeenCalledTimes(2);
|
||||||
|
expect(createClient).toHaveBeenCalledWith(6379, undefined);
|
||||||
|
expect(createClient).toHaveBeenCalledWith(6378, '127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors when invalid string is passed.', async(): Promise<void> => {
|
||||||
|
// Reset calls done in `beforeEach`
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const clientStrings = [ 'noHostOrPort' ];
|
||||||
|
expect((): any => new RedisResourceLocker(clientStrings))
|
||||||
|
.toThrow('Invalid data provided to create a Redis client: noHostOrPort');
|
||||||
|
expect(createClient).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quit()', (): void => {
|
||||||
|
it('should clear all locks and intervals when quit() is called.', async(): Promise<void> => {
|
||||||
|
await locker.acquire(identifier);
|
||||||
|
await locker.quit();
|
||||||
|
expect(redlock.quit).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// This works since the Redlock is simply a mock and quit should have cleared the lockMap
|
||||||
|
await expect(locker.acquire(identifier)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user