вроде все работает

This commit is contained in:
gorbenko.pavel
2026-08-19 17:38:17 +05:00
parent 5ae9fbd64b
commit c22efe5377
13 changed files with 499 additions and 159 deletions
@@ -0,0 +1,67 @@
import { mapWithLimit } from './parallel';
describe('mapWithLimit', () => {
it('возвращает результаты в порядке входного массива, а не в порядке завершения', async () => {
// Первый элемент заведомо медленнее второго — если бы порядок задавало
// завершение, они бы поменялись местами.
const settled = await mapWithLimit(
[30, 0, 10],
delay => new Promise<number>(resolve => setTimeout(() => resolve(delay), delay)),
{ limit: 3 }
);
expect(settled.map(s => (s.ok ? s.value : 'err'))).toEqual([30, 0, 10]);
});
it('отказ одного элемента не прерывает остальные', async () => {
const settled = await mapWithLimit([1, 2, 3], n =>
n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)
);
expect(settled[0]).toEqual({ ok: true, value: 1 });
expect(settled[1].ok).toBe(false);
expect(settled[2]).toEqual({ ok: true, value: 3 });
});
it('не превышает предел одновременных запросов', async () => {
let running = 0;
let peak = 0;
await mapWithLimit(
[1, 2, 3, 4, 5, 6, 7],
() => {
running += 1;
peak = Math.max(peak, running);
return new Promise<void>(resolve =>
setTimeout(() => {
running -= 1;
resolve();
}, 5)
);
},
{ limit: 2 }
);
expect(peak).toBe(2);
});
it('onSettled вызывается по разу на каждый элемент, включая упавшие', async () => {
let done = 0;
await mapWithLimit([1, 2, 3], n => (n === 2 ? Promise.reject(new Error('bang')) : Promise.resolve(n)), {
onSettled: () => {
done += 1;
}
});
expect(done).toBe(3);
});
it('пустой список — пустой результат, fn не вызывается', async () => {
const fn = jest.fn();
const settled = await mapWithLimit([], fn);
expect(settled).toEqual([]);
expect(fn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
/**
* Параллельный запуск с ограничением одновременных запросов и результатом
* «по каждому элементу отдельно».
*
* Зачем свой велосипед вместо Promise.allSettled: его нет в целевом lib этого
* проекта (tsconfig.json — es5/es2015.*, без es2020.promise). Плюс здесь нужен
* не только «не падать целиком», но и ПОТОЛОК одновременных запросов: без него
* пакетная замена на сотню строк уходит в SharePoint сотней параллельных
* запросов и упирается в его троттлинг (429), после чего часть строк не
* записывается — то есть «быстрее» превращается в «половина не сохранилась».
*/
export type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
/** Сколько запросов держим в воздухе по умолчанию — компромисс между скоростью и троттлингом SharePoint. */
export const DEFAULT_CONCURRENCY = 5;
export interface IMapWithLimitOptions {
limit?: number;
/** Вызывается после завершения КАЖДОГО элемента — для полосы прогресса. */
onSettled?: () => void;
}
/**
* Пропускает items через fn, держа не больше limit запросов одновременно.
* Результат — в том же порядке, что items; отказ одного элемента не прерывает
* остальные (см. SettledResult).
*/
export async function mapWithLimit<T, R>(
items: T[],
fn: (item: T, index: number) => Promise<R>,
options: IMapWithLimitOptions = {}
): Promise<Array<SettledResult<R>>> {
const limit = Math.max(1, options.limit ?? DEFAULT_CONCURRENCY);
const results: Array<SettledResult<R>> = new Array(items.length);
let next = 0;
// Ровно limit «рабочих», каждый в цикле забирает следующий свободный индекс.
// Так предел соблюдается точно, без пауз между пачками (в отличие от разбиения
// на чанки, где приходится ждать самый медленный элемент каждой пачки).
const worker = async (): Promise<void> => {
for (;;) {
const index = next;
next += 1;
if (index >= items.length) return;
try {
results[index] = { ok: true, value: await fn(items[index], index) };
} catch (error) {
results[index] = { ok: false, error };
}
if (options.onSettled) options.onSettled();
}
};
await Promise.all(new Array(Math.min(limit, items.length)).fill(0).map(worker));
return results;
}