import { type Logger } from "../types/index.js"; import { defaultLoggersByComponent } from "../utils/utils.js"; /** * Imagine you have an function that, when called, kicks off a task, and * returns a promise representing the result of the task. Now, suppose the task * is expensive, but not effectful, like fetching from a REST API. If your code * attempts to run this task many times in quick succession, or many times in * parallel, this can be wasteful (if the result will always or likely be the * same) and/or overload whatever's doing work as part of the task (eg the API). * * There are a large number of npm packages that make it easy to better schedule * this work (e.g., queueing some parallel runs of the task behind others, if * the number of tasks already running exceeds some concurrency limit) or to * reuse the results of some runs of the task for others (by debouncing or * throttling calls to the function that starts the task). However, none of them * have quite the -- admittedly a bit quirky -- semantics that we want, and that * we implement here. * * Specifically, this function accepts a function for starting/creating tasks, * along with some options, and returns a new function that behaves like the * original except that the promise resulting from a call is reused on * subsequent calls if and only if: 1) the subsequent calls have the same * arguments [a la memoize]; 2) less than a certain number of milliseconds have * passed [a la throttle]; and 3) the original task is still running [unique]. * In other words, it memoizes the original task-starting function, but then * reverts to calling through to the original function after a certain number * of milliseconds or after the last-started task finishes, whichever is first. * So it's sort of a combination of memoize, throttle, and promise chaining. * * @param taskCreator The task creation function * @param collapseTasksMs The number of milliseconds up until which, if a caller * tries to start the same task while a previous version of the task is still * running, the promise for the result of the currently-running task will be * returned instead. * @param key A function for converting the arguments passed to the task * creation into a cache key, like in your standard memoize implementation. */ export default function collapsedTaskCreator( taskCreator: (...args: Args) => Promise, collapseTasksMs: number, // TODO: think about how to make this whole key generation process type safe. // E.g., how to verify that, if JSON.stringify is used, the value is properly // stringifyable? Would it be better to just have no default key option? key: (args: Args) => any = JSON.stringify.bind(JSON), logger: Logger = defaultLoggersByComponent["collapsed-task-creator"], ) { // Tuple of [PromiseForTaskResult, taskStartTimestamp]. const pendingTasks = new Map, number]>(); const logTrace = logger.bind(null, "collapsed-task-creator", "trace"); return async (...args: Args) => { const taskKey = key(args); const res = pendingTasks.get(taskKey); const now = Date.now(); logTrace("requested = new state for taskKey/args", { args, taskKey }); if (!res || now - res[1] > collapseTasksMs) { logTrace( res ? "started new task; there _was_ an in-progress one, but it's too old" : "started new task b/c there was no in-progress task for these args", args, ); const taskRes = taskCreator(...args).finally(async () => { // Only remove this task from pendingTasks if pendingTasks[taskKey] // is still the same task. (It could be a new one if the old one was // overwritten for taking longer than collapseTasksMs.) const pendingValueNow = pendingTasks.get(taskKey); if (pendingValueNow && pendingValueNow[0] === taskRes) { logTrace("completed = new state for taskKey/args", { args, taskKey }); pendingTasks.delete(taskKey); } }); // Save the new task as a pending task. This will be _replacing_ // an existing pending task for this key if the other was too old. pendingTasks.set(taskKey, [taskRes, now]); logTrace("pending = new state for taskKey/args", { args, taskKey }); return taskRes; } logTrace( "reusing result from prior, still-in-progress run of task for args/taskKey", { args, taskKey }, ); return res[0]; }; }