91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { runWithConcurrency } from '../../src/client/concurrency.js';
|
||
|
|
|
||
|
|
async function* range(n: number): AsyncIterable<number> {
|
||
|
|
for (let i = 0; i < n; i++) yield i;
|
||
|
|
}
|
||
|
|
|
||
|
|
function delay(ms: number): Promise<void> {
|
||
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('runWithConcurrency', () => {
|
||
|
|
test('runs all items', async () => {
|
||
|
|
const seen: number[] = [];
|
||
|
|
await runWithConcurrency(range(10), async (i) => {
|
||
|
|
seen.push(i);
|
||
|
|
}, { concurrency: 4 });
|
||
|
|
expect(seen.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('respects concurrency cap (never exceeds N inflight)', async () => {
|
||
|
|
let inflight = 0;
|
||
|
|
let peak = 0;
|
||
|
|
await runWithConcurrency(range(50), async () => {
|
||
|
|
inflight++;
|
||
|
|
peak = Math.max(peak, inflight);
|
||
|
|
await delay(5);
|
||
|
|
inflight--;
|
||
|
|
}, { concurrency: 4 });
|
||
|
|
expect(peak).toBeLessThanOrEqual(4);
|
||
|
|
expect(peak).toBeGreaterThanOrEqual(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('throws on first error by default (fail-fast)', async () => {
|
||
|
|
let processed = 0;
|
||
|
|
await expect(
|
||
|
|
runWithConcurrency(range(20), async (i) => {
|
||
|
|
await delay(1);
|
||
|
|
if (i === 3) throw new Error('boom');
|
||
|
|
processed++;
|
||
|
|
}, { concurrency: 2 }),
|
||
|
|
).rejects.toThrow('boom');
|
||
|
|
// We don't process all 20; bounded by fail-fast.
|
||
|
|
expect(processed).toBeLessThan(20);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('continueOnError reports each + drains', async () => {
|
||
|
|
const errors: number[] = [];
|
||
|
|
let processed = 0;
|
||
|
|
await runWithConcurrency(range(10), async (i) => {
|
||
|
|
await delay(1);
|
||
|
|
if (i % 3 === 0) throw new Error(`bad-${i}`);
|
||
|
|
processed++;
|
||
|
|
}, {
|
||
|
|
concurrency: 3,
|
||
|
|
continueOnError: true,
|
||
|
|
onError: (item) => errors.push(item),
|
||
|
|
});
|
||
|
|
expect(processed).toBe(6); // i = 1,2,4,5,7,8
|
||
|
|
expect(errors.sort((a, b) => a - b)).toEqual([0, 3, 6, 9]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('aborts via signal', async () => {
|
||
|
|
const ctrl = new AbortController();
|
||
|
|
let processed = 0;
|
||
|
|
setTimeout(() => ctrl.abort(), 20);
|
||
|
|
await expect(
|
||
|
|
runWithConcurrency(range(100), async () => {
|
||
|
|
await delay(5);
|
||
|
|
processed++;
|
||
|
|
}, { concurrency: 4, signal: ctrl.signal }),
|
||
|
|
).rejects.toThrow();
|
||
|
|
expect(processed).toBeLessThan(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('concurrency=1 is sequential', async () => {
|
||
|
|
const order: number[] = [];
|
||
|
|
await runWithConcurrency(range(5), async (i) => {
|
||
|
|
order.push(i);
|
||
|
|
await delay(1);
|
||
|
|
}, { concurrency: 1 });
|
||
|
|
expect(order).toEqual([0, 1, 2, 3, 4]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('throws on concurrency < 1', () => {
|
||
|
|
expect(() =>
|
||
|
|
runWithConcurrency(range(0), async () => undefined, { concurrency: 0 }),
|
||
|
|
).toThrow('concurrency must be ≥ 1');
|
||
|
|
});
|
||
|
|
});
|