import { EventEmitter } from 'events'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import { ForbiddenHttpError } from '../../../../src/util/errors/ForbiddenHttpError'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; import { GreedyReadWriteLocker } from '../../../../src/util/locking/GreedyReadWriteLocker'; import type { ResourceLocker } from '../../../../src/util/locking/ResourceLocker'; import { flushPromises } from '../../../util/Util'; // A simple ResourceLocker that keeps a queue of lock requests class MemoryLocker implements ResourceLocker { private readonly locks: Record void)[]>; public constructor() { this.locks = {}; } public async acquire(identifier: ResourceIdentifier): Promise { const { path } = identifier; if (!this.locks[path]) { this.locks[path] = []; } else { return new Promise((resolve): void => { this.locks[path].push(resolve); }); } } public async release(identifier: ResourceIdentifier): Promise { const { path } = identifier; if (this.locks[path].length > 0) { this.locks[path].shift()!(); } else { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.locks[path]; } } } describe('A GreedyReadWriteLocker', (): void => { let sourceLocker: ResourceLocker; let storage: KeyValueStorage; const resourceId = { path: 'http://test.com/resource' }; const resource2Id = { path: 'http://test.com/resource2' }; let locker: GreedyReadWriteLocker; beforeEach(async(): Promise => { sourceLocker = new MemoryLocker(); storage = new Map() as any; locker = new GreedyReadWriteLocker(sourceLocker, storage); }); it('does not block single read operations.', async(): Promise => { await expect(locker.withReadLock(resourceId, (): any => 5)).resolves.toBe(5); }); it('does not block single write operations.', async(): Promise => { await expect(locker.withWriteLock(resourceId, (): any => 5)).resolves.toBe(5); }); it('errors when trying to writeLock a count identifier.', async(): Promise => { await expect(locker.withWriteLock({ path: `http://test.com/foo.count` }, (): any => 5)) .rejects.toThrow(ForbiddenHttpError); locker = new GreedyReadWriteLocker(sourceLocker, storage, { count: 'dummy', read: 'read', write: 'write' }); await expect(locker.withWriteLock({ path: `http://test.com/foo.dummy` }, (): any => 5)) .rejects.toThrow(ForbiddenHttpError); }); it('errors if the read counter has an unexpected value.', async(): Promise => { storage.get = jest.fn().mockResolvedValue(0); await expect(locker.withReadLock(resourceId, (): any => 5)).rejects.toThrow(InternalServerError); }); it('does not block multiple read operations.', async(): Promise => { const order: string[] = []; const emitter = new EventEmitter(); const unlocks = [ 0, 1, 2 ].map((num): any => new Promise((resolve): any => emitter.on(`release${num}`, resolve))); const promises = [ 0, 1, 2 ].map((num): any => locker.withReadLock(resourceId, async(): Promise => { order.push(`start ${num}`); await unlocks[num]; order.push(`finish ${num}`); return num; })); // Allow time to attach listeners await flushPromises(); emitter.emit('release2'); await expect(promises[2]).resolves.toBe(2); emitter.emit('release0'); await expect(promises[0]).resolves.toBe(0); emitter.emit('release1'); await expect(promises[1]).resolves.toBe(1); expect(order).toEqual([ 'start 0', 'start 1', 'start 2', 'finish 2', 'finish 0', 'finish 1' ]); }); it('blocks multiple write operations.', async(): Promise => { // Previous test but with write locks const order: string[] = []; const emitter = new EventEmitter(); const unlocks = [ 0, 1, 2 ].map((num): any => new Promise((resolve): any => emitter.on(`release${num}`, resolve))); const promises = [ 0, 1, 2 ].map((num): any => locker.withWriteLock(resourceId, async(): Promise => { order.push(`start ${num}`); await unlocks[num]; order.push(`finish ${num}`); return num; })); // Allow time to attach listeners await flushPromises(); emitter.emit('release2'); // Allow time to finish write 2 await flushPromises(); emitter.emit('release0'); emitter.emit('release1'); await Promise.all([ promises[2], promises[0], promises[1] ]); expect(order).toEqual([ 'start 0', 'finish 0', 'start 1', 'finish 1', 'start 2', 'finish 2' ]); }); it('allows multiple write operations on different resources.', async(): Promise => { // Previous test but with write locks const order: string[] = []; const emitter = new EventEmitter(); const resources = [ resourceId, resource2Id ]; const unlocks = [ 0, 1 ].map((num): any => new Promise((resolve): any => emitter.on(`release${num}`, resolve))); const promises = [ 0, 1 ].map((num): any => locker.withWriteLock(resources[num], async(): Promise => { order.push(`start ${num}`); await unlocks[num]; order.push(`finish ${num}`); return num; })); // Allow time to attach listeners await flushPromises(); emitter.emit('release1'); await expect(promises[1]).resolves.toBe(1); emitter.emit('release0'); await expect(promises[0]).resolves.toBe(0); expect(order).toEqual([ 'start 0', 'start 1', 'finish 1', 'finish 0' ]); }); it('blocks write operations during read operations.', async(): Promise => { const order: string[] = []; const emitter = new EventEmitter(); const promRead = new Promise((resolve): any => { emitter.on('releaseRead', resolve); }); // We want to make sure the write operation only starts while the read operation is busy // Otherwise the internal write lock might not be acquired yet const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resourceId, (): any => { order.push('write'); resolve(); }); }); }); const lockRead = locker.withReadLock(resourceId, async(): Promise => { emitter.emit('readStarted'); order.push('read start'); await promRead; order.push('read finish'); }); // Allow time to attach listeners await flushPromises(); const promAll = Promise.all([ delayedLockWrite, lockRead ]); emitter.emit('releaseRead'); await promAll; expect(order).toEqual([ 'read start', 'read finish', 'write' ]); }); it('allows write operations on different resources during read operations.', async(): Promise => { const order: string[] = []; const emitter = new EventEmitter(); const promRead = new Promise((resolve): any => { emitter.on('releaseRead', resolve); }); const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resource2Id, (): any => { order.push('write'); resolve(); }); }); }); const lockRead = locker.withReadLock(resourceId, async(): Promise => { emitter.emit('readStarted'); order.push('read start'); await promRead; order.push('read finish'); }); // Allow time to attach listeners await flushPromises(); const promAll = Promise.all([ delayedLockWrite, lockRead ]); emitter.emit('releaseRead'); await promAll; expect(order).toEqual([ 'read start', 'write', 'read finish' ]); }); it('prioritizes read operations when a read operation is waiting.', async(): Promise => { // This test is very similar to the previous ones but adds an extra read lock const order: string[] = []; const emitter = new EventEmitter(); const promRead1 = new Promise((resolve): any => emitter.on('releaseRead1', resolve)); const promRead2 = new Promise((resolve): any => emitter.on('releaseRead2', resolve)); const delayedLockWrite = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withWriteLock(resourceId, (): any => { order.push('write'); resolve(); }); }); }); const delayedLockRead2 = new Promise((resolve): void => { emitter.on('readStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withReadLock(resourceId, async(): Promise => { order.push('read 2 start'); await promRead2; order.push('read 2 finish'); resolve(); }); }); }); const lockRead = locker.withReadLock(resourceId, async(): Promise => { emitter.emit('readStarted'); order.push('read 1 start'); await promRead1; order.push('read 1 finish'); }); // Allow time to attach listeners await flushPromises(); const promAll = Promise.all([ delayedLockWrite, lockRead, delayedLockRead2 ]); emitter.emit('releaseRead1'); // Allow time to finish read 1 await flushPromises(); emitter.emit('releaseRead2'); await promAll; expect(order).toEqual([ 'read 1 start', 'read 2 start', 'read 1 finish', 'read 2 finish', 'write' ]); }); it('blocks read operations during write operations.', async(): Promise => { // Again similar but with read and write order switched const order: string[] = []; const emitter = new EventEmitter(); const promWrite = new Promise((resolve): any => { emitter.on('releaseWrite', resolve); }); // We want to make sure the read operation only starts while the write operation is busy const delayedLockRead = new Promise((resolve): void => { emitter.on('writeStarted', (): void => { // eslint-disable-next-line @typescript-eslint/no-floating-promises locker.withReadLock(resourceId, (): any => { order.push('read'); resolve(); }); }); }); const lockWrite = locker.withWriteLock(resourceId, async(): Promise => { emitter.emit('writeStarted'); order.push('write start'); await promWrite; order.push('write finish'); }); // Allow time to attach listeners await flushPromises(); const promAll = Promise.all([ delayedLockRead, lockWrite ]); emitter.emit('releaseWrite'); await promAll; expect(order).toEqual([ 'write start', 'write finish', 'read' ]); }); });