handle errors from cache management callbacks

This commit is contained in:
Hazelnoot 2025-11-02 11:03:48 -05:00
parent 4336a5d214
commit 151550602c
3 changed files with 189 additions and 10 deletions

View file

@ -18,6 +18,7 @@ import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import { TimeService, type TimerHandle } from '@/global/TimeService.js';
import { InternalEventService } from '@/global/InternalEventService.js';
import { callAllOn } from '@/misc/call-all.js';
// This is the one place that's *supposed* to new() up caches.
/* eslint-disable no-restricted-syntax */
@ -97,29 +98,25 @@ export class CacheManagementService implements OnApplicationShutdown {
@bindThis
public gc(): void {
this.resetGcTimer(() => {
// TODO callAll()
for (const manager of this.managedCaches) {
manager.gc();
}
callAllOn(this.managedCaches, 'gc');
});
}
@bindThis
public clear(): void {
this.resetGcTimer(() => {
for (const manager of this.managedCaches) {
manager.clear();
}
callAllOn(this.managedCaches, 'clear');
});
}
@bindThis
public async dispose(): Promise<void> {
this.stopGcTimer();
for (const manager of this.managedCaches) {
manager.dispose();
}
const toDispose = new Set(this.managedCaches);
this.managedCaches.clear();
callAllOn(toDispose, 'dispose');
}
@bindThis

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Calls a group of functions with the given parameters.
* Errors are suppressed and aggregated, ensuring that nothing is thrown until all calls have completed.
* This ensures that an error in one callback does not prevent later callbacks from completing.
* @param funcs Callback functions to execute
* @param args Arguments to pass to each callback
*/
export function callAll<T extends unknown[]>(funcs: Iterable<(...args: T) => void>, ...args: T): void {
const errors: unknown[] = [];
for (const func of funcs) {
try {
func(...args);
} catch (err) {
errors.push(err);
}
}
if (errors.length > 0) {
throw new AggregateError(errors);
}
}
/**
* Calls a single method across a group of object, passing the given parameters as values.
* Errors are suppressed and aggregated, ensuring that nothing is thrown until all calls have completed.
* This ensures that an error in one callback does not prevent later callbacks from completing.
* @param objects Objects to execute methods on
* @param method Name (property key) of the method to execute
* @param args Arguments to pass
*/
export function callAllOn<TObject, TMethod extends MethodKeys<TObject>>(objects: Iterable<TObject>, method: TMethod, ...args: MethodParams<TObject, TMethod>): void {
const errors: unknown[] = [];
for (const object of objects) {
try {
// @ts-expect-error Our generic constraints ensure this is safe, but TS can't infer that much context.
object[method](...args);
} catch (err) {
errors.push(err);
}
}
if (errors.length > 0) {
throw new AggregateError(errors);
}
}
type AnyFunc = (...args: unknown[]) => unknown;
type Methods<TObject> = {
[Key in keyof TObject]: TObject[Key] extends AnyFunc ? TObject[Key] : never;
};
type MethodKeys<TObject> = keyof Methods<TObject>;
type MethodParams<TObject, TMethod extends MethodKeys<TObject>> = TObject[TMethod] extends AnyFunc ? Parameters<TObject[TMethod]> : never;

View file

@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
import { callAll, callAllOn } from '@/misc/call-all.js';
describe(callAll, () => {
it('should call all functions when all succeed', () => {
const funcs = [
jest.fn(() => {}),
jest.fn(() => {}),
jest.fn(() => {}),
];
callAll(funcs);
for (const func of funcs) {
expect(func).toHaveBeenCalledTimes(1);
}
});
it('should pass parameters to all functions', () => {
const funcs = [
jest.fn((num: number) => expect(num).toBe(1)),
jest.fn((num: number) => expect(num).toBe(1)),
jest.fn((num: number) => expect(num).toBe(1)),
];
callAll(funcs, 1);
});
it('should call all functions when some fail', () => {
const funcs = [
jest.fn(() => { throw new Error(); }),
jest.fn(() => {}),
jest.fn(() => {}),
];
try {
callAll(funcs);
} catch {
// ignore
}
for (const func of funcs) {
expect(func).toHaveBeenCalledTimes(1);
}
});
it('should throw when some functions fail', () => {
const funcs = [
jest.fn(() => { throw new Error(); }),
jest.fn(() => {}),
jest.fn(() => {}),
];
expect(() => callAll(funcs)).toThrow();
});
it('should not throw when input is empty', () => {
expect(() => callAll([])).not.toThrow();
});
});
describe(callAllOn, () => {
it('should call all methods when all succeed', () => {
const objects = [
{ foo: jest.fn(() => {}) },
{ foo: jest.fn(() => {}) },
{ foo: jest.fn(() => {}) },
];
callAllOn(objects, 'foo');
for (const object of objects) {
expect(object.foo).toHaveBeenCalledTimes(1);
}
});
it('should pass parameters to all methods', () => {
const objects = [
{ foo: jest.fn((num: number) => expect(num).toBe(1)) },
{ foo: jest.fn((num: number) => expect(num).toBe(1)) },
{ foo: jest.fn((num: number) => expect(num).toBe(1)) },
];
callAllOn(objects, 'foo', 1);
});
it('should call all methods when some fail', () => {
const objects = [
{ foo: jest.fn(() => {}) },
{ foo: jest.fn(() => {}) },
{ foo: jest.fn(() => {}) },
];
try {
callAllOn(objects, 'foo');
} catch {
// ignore
}
for (const object of objects) {
expect(object.foo).toHaveBeenCalledTimes(1);
}
});
it('should throw when some methods fail', () => {
const objects = [
{ foo: jest.fn(() => { throw new Error(); }) },
{ foo: jest.fn(() => {}) },
{ foo: jest.fn(() => {}) },
];
expect(() => callAllOn(objects, 'foo')).toThrow();
});
it('should not throw when input is empty', () => {
expect(() => callAllOn([] as { foo: () => void }[], 'foo')).not.toThrow();
});
});