Skip to content

Commit f08cdf7

Browse files
committed
fix: Prevent expired storage cleanup from crashing the server
1 parent 16e9368 commit f08cdf7

File tree

4 files changed

+88
-1
lines changed

4 files changed

+88
-1
lines changed

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,5 @@ export * from './util/RecordObject';
435435
export * from './util/ResourceUtil';
436436
export * from './util/StreamUtil';
437437
export * from './util/TermUtil';
438+
export * from './util/TimerUtil';
438439
export * from './util/Vocabularies';

src/storage/keyvalue/WrappedExpiringStorage.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Finalizable } from '../../init/final/Finalizable';
22
import { getLoggerFor } from '../../logging/LogUtil';
33
import { InternalServerError } from '../../util/errors/InternalServerError';
4+
import { setSafeInterval } from '../../util/TimerUtil';
45
import type { ExpiringStorage } from './ExpiringStorage';
56
import type { KeyValueStorage } from './KeyValueStorage';
67

@@ -23,7 +24,10 @@ export class WrappedExpiringStorage<TKey, TValue> implements ExpiringStorage<TKe
2324
*/
2425
public constructor(source: KeyValueStorage<TKey, Expires<TValue>>, timeout = 60) {
2526
this.source = source;
26-
this.timer = setInterval(this.removeExpiredEntries.bind(this), timeout * 60 * 1000);
27+
this.timer = setSafeInterval(this.logger,
28+
'Failed to remove expired entries',
29+
this.removeExpiredEntries.bind(this),
30+
timeout * 60 * 1000);
2731
}
2832

2933
public async get(key: TKey): Promise<TValue | undefined> {

src/util/TimerUtil.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Logger } from '../logging/Logger';
2+
import { createErrorMessage } from './errors/ErrorUtil';
3+
4+
/**
5+
* Wraps the callback for {@link setInterval} so errors get caught and logged.
6+
* Parameters are identical to the {@link setInterval} parameters starting from the 3rd argument.
7+
* The logger and message will be used when the callback throws an error.
8+
* Supports asynchronous callback functions.
9+
*/
10+
export function setSafeInterval(logger: Logger, message: string, callback: (...cbArgs: any[]) => void, ms?: number,
11+
...args: any[]): NodeJS.Timeout {
12+
async function safeCallback(...cbArgs: any[]): Promise<void> {
13+
try {
14+
// We don't know if the callback is async or not so this way we make sure
15+
// the full function execution is done in the try block.
16+
// eslint-disable-next-line @typescript-eslint/await-thenable
17+
return await callback(...cbArgs);
18+
} catch (error: unknown) {
19+
logger.error(`Error during interval callback: ${message} - ${createErrorMessage(error)}`);
20+
}
21+
}
22+
return setInterval(safeCallback, ms, ...args);
23+
}

test/unit/util/TimerUtil.test.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Logger } from '../../../src/logging/Logger';
2+
import { setSafeInterval } from '../../../src/util/TimerUtil';
3+
import { flushPromises } from '../../util/Util';
4+
5+
jest.useFakeTimers();
6+
7+
describe('TimerUtil', (): void => {
8+
describe('#setSafeInterval', (): void => {
9+
let logger: jest.Mocked<Logger>;
10+
let callback: jest.Mock<(...cbArgs: any[]) => void>;
11+
12+
beforeEach(async(): Promise<void> => {
13+
logger = { error: jest.fn() } as any;
14+
callback = jest.fn();
15+
});
16+
17+
it('creates a working interval.', async(): Promise<void> => {
18+
const timer = setSafeInterval(logger, 'message', callback, 1000, 'argument');
19+
20+
jest.advanceTimersByTime(1000);
21+
22+
expect(callback).toHaveBeenCalledTimes(1);
23+
expect(callback).toHaveBeenLastCalledWith('argument');
24+
expect(logger.error).toHaveBeenCalledTimes(0);
25+
26+
clearInterval(timer);
27+
});
28+
29+
it('logs an error if something went wrong in the callback.', async(): Promise<void> => {
30+
const timer = setSafeInterval(logger, 'message', callback, 1000, 'argument');
31+
callback.mockImplementationOnce((): never => {
32+
throw new Error('callback error');
33+
});
34+
35+
jest.advanceTimersByTime(1000);
36+
37+
expect(callback).toHaveBeenCalledTimes(1);
38+
expect(callback).toHaveBeenLastCalledWith('argument');
39+
expect(logger.error).toHaveBeenCalledTimes(1);
40+
expect(logger.error).toHaveBeenLastCalledWith('Error during interval callback: message - callback error');
41+
42+
clearInterval(timer);
43+
});
44+
45+
it('correctly handles errors in async callbacks.', async(): Promise<void> => {
46+
const promCallback = jest.fn().mockRejectedValue(new Error('callback error'));
47+
const timer = setSafeInterval(logger, 'message', promCallback, 1000, 'argument');
48+
49+
jest.advanceTimersByTime(1000);
50+
await flushPromises();
51+
52+
expect(promCallback).toHaveBeenCalledTimes(1);
53+
expect(logger.error).toHaveBeenCalledTimes(1);
54+
expect(logger.error).toHaveBeenLastCalledWith('Error during interval callback: message - callback error');
55+
56+
clearInterval(timer);
57+
});
58+
});
59+
});

0 commit comments

Comments
 (0)