async-task-runner-js
Version:
Empower your single-threaded JavaScript with intelligent task pooling, throttling, and prioritization. A better alternative to Promise.all() for managing thousands of async operations without overwhelming your resources.
151 lines (150 loc) • 5.91 kB
JavaScript
/**
* Sleep utility function
* @param ms - Milliseconds to sleep
* @returns Promise that resolves after the specified time
*/
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
}
/**
* Generate a unique task ID
*/
function generateTaskUniqueId() {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Creates an async task runner with pooling and throttling capabilities
*
* Why use this instead of Promise.all()?
* - Promise.all() has no control over concurrency - all promises start immediately
* - This runner controls the maximum number of parallel tasks (pooling)
* - Supports task prioritization
* - Provides progress tracking and logging
* - Handles throttling to prevent overwhelming resources
* - Supports expiration time to stop processing after a deadline
*
* @param taskName - Name for logging purposes
* @param options - Configuration options
* @returns AsyncTaskRunner instance
*/
export function createAsyncTasksRunner(taskName, options) {
const tickInterval = options?.tickInterval || 10;
const maxInParallel = options?.maxInParallel || 20;
const logProgressWhenFinishing = options?.logProgressWhenFinishing;
const expirationTime = options?.expirationTime;
const state = {
lastLogProgressWhenFinishing: 0,
finishedCounter: 0,
finishedChangeTime: 0,
pendingCounter: 0,
pendingChangeTime: 0,
runningCounter: 0,
runningChangeTime: 0,
isWaitingToFinish: false,
};
const pendingTasks = {};
const logProgress = (...logSuffixes) => {
const finishedCounterSnapshot = state.finishedCounter;
const pendingCounterSnapshot = state.pendingCounter;
const runningCounterSnapshot = state.runningCounter;
const totalCounterSnapshot = finishedCounterSnapshot + pendingCounterSnapshot + runningCounterSnapshot;
console.log(`${taskName} progress: ${finishedCounterSnapshot} of ${totalCounterSnapshot}(p: ${pendingCounterSnapshot} | r: ${runningCounterSnapshot}) finishes!`, ...logSuffixes);
};
const logFinish = () => {
console.log(`${taskName} finished! ${state.finishedCounter} tasks`);
};
const startRunner = async () => {
while (true) {
// should log progress?
const lastLogFinishedDiff = state.finishedCounter - state.lastLogProgressWhenFinishing;
if (lastLogFinishedDiff > (logProgressWhenFinishing || 0)) {
state.lastLogProgressWhenFinishing = state.finishedCounter;
logProgress();
}
// nothing pending or running? finish or next tick
if (!state.pendingCounter) {
if (!state.runningCounter && state.isWaitingToFinish)
return;
await sleep(tickInterval);
continue;
}
// stop running more tasks when expirationTime has been reached
if (expirationTime && Date.now() >= expirationTime) {
if (state.runningCounter) {
await sleep(tickInterval);
continue;
}
if (!state.runningCounter)
return;
}
// running at maximum capacity? next tick
if (state.runningCounter >= maxInParallel) {
await sleep(tickInterval);
continue;
}
state.runningChangeTime = Date.now();
// Sort pending tasks by priority (descending) and time (ascending - oldest first)
const nextQueue = Object.entries(pendingTasks)
.map(([id, t]) => ({
id,
t,
p: t.priority || 0,
o: t.time,
}))
.sort((a, b) => {
// First sort by priority (higher priority first)
if (b.p !== a.p)
return b.p - a.p;
// Then by time (older tasks first)
return a.o - b.o;
});
let nextQueueIndex = 0;
while (!!state.pendingCounter && state.runningCounter < maxInParallel) {
// pick next enqueued task by priority and order, start to run it attaching finish instructions
const taskUniqueId = nextQueue[nextQueueIndex]?.id;
const task = nextQueue[nextQueueIndex]?.t.task;
if (!taskUniqueId || !task)
break;
state.runningCounter++;
delete pendingTasks[taskUniqueId];
state.pendingCounter--;
task()
.catch((error) => {
console.error(taskName, "single task failed unexpectedly!", taskUniqueId, error);
})
.finally(() => {
state.finishedCounter++;
state.finishedChangeTime = Date.now();
state.runningCounter--;
});
nextQueueIndex++;
}
await sleep(tickInterval);
}
};
const runner = startRunner().catch((error) => {
console.error(taskName, "failed unexpectedly! Async task runner failure", error);
});
const addTask = (task, priority) => {
const taskUniqueId = generateTaskUniqueId();
const time = Date.now();
const asyncTask = { task, time };
if (priority)
asyncTask.priority = priority;
pendingTasks[taskUniqueId] = asyncTask;
state.pendingCounter++;
state.pendingChangeTime = Date.now();
};
const finishAll = async () => {
state.isWaitingToFinish = true;
await runner;
logFinish();
};
return {
addTask,
finishAll,
logProgress,
};
}