implement callAllAsync and callAllOnAsync utilities

This commit is contained in:
Hazelnoot 2025-11-11 22:40:26 -05:00
parent c777b79431
commit 6387b36dc3
2 changed files with 185 additions and 4 deletions

View file

@ -3,8 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promiseTry } from '@/misc/promise-try.js';
/**
* Calls a group of functions with the given parameters.
* Calls a group of synchronous 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
@ -27,7 +29,33 @@ export function callAll<T extends unknown[]>(funcs: Iterable<(...args: T) => voi
}
/**
* Calls a single method across a group of object, passing the given parameters as values.
* Calls a group of async 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.
* Callbacks are executed in parallel using Promise.allSettled().
* @param funcs Callback functions to execute
* @param args Arguments to pass to each callback
*/
export async function callAllAsync<T extends unknown[]>(funcs: Iterable<(...args: T) => Promise<void> | void>, ...args: T): Promise<void> {
// Start all the tasks
const promises = Array.from(funcs)
.map(func => {
// Handle errors thrown synchronously
return promiseTry(() => func(...args));
});
// Wait for all to finish
const results = await Promise.allSettled(promises);
// Check for errors
const errors = results.filter(r => r.status === 'rejected').map(r => r.reason as unknown);
if (errors.length > 0) {
throw new AggregateError(errors);
}
}
/**
* Calls a single synchronous 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
@ -51,6 +79,36 @@ export function callAllOn<TObject, TMethod extends MethodKeys<TObject>>(objects:
}
}
/**
* Calls a single asynchronous 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.
* Callbacks are executed in parallel using Promise.allSettled().
* @param objects Objects to execute methods on
* @param method Name (property key) of the method to execute
* @param args Arguments to pass
*/
export async function callAllOnAsync<TObject, TMethod extends MethodKeys<TObject>>(objects: Iterable<TObject>, method: TMethod, ...args: MethodParams<TObject, TMethod>): Promise<void> {
// Start all the tasks
const promises = Array.from(objects)
.map(object => {
// Handle errors thrown synchronously
return promiseTry(() => {
// @ts-expect-error Our generic constraints ensure this is safe, but TS can't infer that much context.
return object[method](...args);
});
});
// Wait for all to finish
const results = await Promise.allSettled(promises);
// Check for errors
const errors = results.filter(r => r.status === 'rejected').map(r => r.reason as unknown);
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;

View file

@ -4,7 +4,8 @@
*/
import { jest } from '@jest/globals';
import { callAll, callAllOn } from '@/misc/call-all.js';
import * as assert from '../../misc/custom-assertions.js';
import { callAll, callAllOn, callAllAsync, callAllOnAsync } from '@/misc/call-all.js';
describe(callAll, () => {
it('should call all functions when all succeed', () => {
@ -56,7 +57,9 @@ describe(callAll, () => {
jest.fn(() => {}),
];
expect(() => callAll(funcs)).toThrow();
assert.throws(AggregateError, () => {
callAll(funcs);
});
});
it('should not throw when input is empty', () => {
@ -64,6 +67,66 @@ describe(callAll, () => {
});
});
describe(callAllAsync, () => {
it('should call all functions when all succeed', async () => {
const funcs = [
jest.fn(() => Promise.resolve()),
jest.fn(() => Promise.resolve()),
jest.fn(() => Promise.resolve()),
];
await callAllAsync(funcs);
for (const func of funcs) {
expect(func).toHaveBeenCalledTimes(1);
}
});
it('should pass parameters to all functions', async () => {
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)),
];
await callAllAsync(funcs, 1);
});
it('should call all functions when some fail', async () => {
const funcs = [
jest.fn(() => Promise.reject(new Error())),
jest.fn(() => Promise.resolve()),
jest.fn(() => Promise.resolve()),
];
try {
await callAllAsync(funcs);
} catch {
// ignore
}
for (const func of funcs) {
expect(func).toHaveBeenCalledTimes(1);
}
});
it('should throw when some functions fail', async () => {
const funcs = [
jest.fn(() => Promise.reject(new Error())),
jest.fn(() => Promise.resolve()),
jest.fn(() => Promise.resolve()),
];
await assert.throwsAsync(AggregateError, async () => {
await callAllAsync(funcs);
});
});
it('should not throw when input is empty', async () => {
await callAllAsync([]);
});
});
describe(callAllOn, () => {
it('should call all methods when all succeed', () => {
const objects = [
@ -121,3 +184,63 @@ describe(callAllOn, () => {
expect(() => callAllOn([] as { foo: () => void }[], 'foo')).not.toThrow();
});
});
describe(callAllOnAsync, () => {
it('should call all methods when all succeed', async () => {
const objects = [
{ foo: jest.fn(() => Promise.resolve()) },
{ foo: jest.fn(() => Promise.resolve()) },
{ foo: jest.fn(() => Promise.resolve()) },
];
await callAllOnAsync(objects, 'foo');
for (const object of objects) {
expect(object.foo).toHaveBeenCalledTimes(1);
}
});
it('should pass parameters to all methods', async () => {
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)) },
];
await callAllOnAsync(objects, 'foo', 1);
});
it('should call all methods when some fail', async () => {
const objects = [
{ foo: jest.fn(() => Promise.resolve()) },
{ foo: jest.fn(() => Promise.resolve()) },
{ foo: jest.fn(() => Promise.resolve()) },
];
try {
await callAllOnAsync(objects, 'foo');
} catch {
// ignore
}
for (const object of objects) {
expect(object.foo).toHaveBeenCalledTimes(1);
}
});
it('should throw when some methods fail', async () => {
const objects = [
{ foo: jest.fn(() => Promise.reject(new Error())) },
{ foo: jest.fn(() => Promise.resolve()) },
{ foo: jest.fn(() => Promise.resolve()) },
];
await assert.throwsAsync(AggregateError, async () => {
await callAllOnAsync(objects, 'foo');
});
});
it('should not throw when input is empty', async () => {
await callAllOnAsync([] as { foo: () => void }[], 'foo');
});
});