From 6387b36dc3dba49d37d7bd329b42fa1693b888eb Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 11 Nov 2025 22:40:26 -0500 Subject: [PATCH] implement callAllAsync and callAllOnAsync utilities --- packages/backend/src/misc/call-all.ts | 62 ++++++++- .../backend/test/unit/misc/call-all-tests.ts | 127 +++++++++++++++++- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/misc/call-all.ts b/packages/backend/src/misc/call-all.ts index b5fd6bb19f..08e23d34ab 100644 --- a/packages/backend/src/misc/call-all.ts +++ b/packages/backend/src/misc/call-all.ts @@ -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(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(funcs: Iterable<(...args: T) => Promise | void>, ...args: T): Promise { + // 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>(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>(objects: Iterable, method: TMethod, ...args: MethodParams): Promise { + // 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 = { [Key in keyof TObject]: TObject[Key] extends AnyFunc ? TObject[Key] : never; diff --git a/packages/backend/test/unit/misc/call-all-tests.ts b/packages/backend/test/unit/misc/call-all-tests.ts index 487e1f03b1..9d8ae0ec2c 100644 --- a/packages/backend/test/unit/misc/call-all-tests.ts +++ b/packages/backend/test/unit/misc/call-all-tests.ts @@ -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'); + }); +});