Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Logger } from "../types/index.js";
2import { defaultLoggersByComponent } from "../utils/utils.js";
3
4/**
5 * Imagine you have an function that, when called, kicks off a task, and
6 * returns a promise representing the result of the task. Now, suppose the task
7 * is expensive, but not effectful, like fetching from a REST API. If your code
8 * attempts to run this task many times in quick succession, or many times in
9 * parallel, this can be wasteful (if the result will always or likely be the
10 * same) and/or overload whatever's doing work as part of the task (eg the API).
11 *
12 * There are a large number of npm packages that make it easy to better schedule
13 * this work (e.g., queueing some parallel runs of the task behind others, if
14 * the number of tasks already running exceeds some concurrency limit) or to
15 * reuse the results of some runs of the task for others (by debouncing or
16 * throttling calls to the function that starts the task). However, none of them
17 * have quite the -- admittedly a bit quirky -- semantics that we want, and that
18 * we implement here.
19 *
20 * Specifically, this function accepts a function for starting/creating tasks,
21 * along with some options, and returns a new function that behaves like the
22 * original except that the promise resulting from a call is reused on
23 * subsequent calls if and only if: 1) the subsequent calls have the same
24 * arguments [a la memoize]; 2) less than a certain number of milliseconds have
25 * passed [a la throttle]; and 3) the original task is still running [unique].
26 * In other words, it memoizes the original task-starting function, but then
27 * reverts to calling through to the original function after a certain number
28 * of milliseconds or after the last-started task finishes, whichever is first.
29 * So it's sort of a combination of memoize, throttle, and promise chaining.
30 *
31 * @param taskCreator The task creation function
32 * @param collapseTasksMs The number of milliseconds up until which, if a caller
33 * tries to start the same task while a previous version of the task is still
34 * running, the promise for the result of the currently-running task will be
35 * returned instead.
36 * @param key A function for converting the arguments passed to the task
37 * creation into a cache key, like in your standard memoize implementation.
38 */
39export default function collapsedTaskCreator<Args extends unknown[], Result>(
40 taskCreator: (...args: Args) => Promise<Result>,
41 collapseTasksMs: number,
42 // TODO: think about how to make this whole key generation process type safe.
43 // E.g., how to verify that, if JSON.stringify is used, the value is properly
44 // stringifyable? Would it be better to just have no default key option?
45 key: (args: Args) => any = JSON.stringify.bind(JSON),
46 logger: Logger = defaultLoggersByComponent["collapsed-task-creator"],
47) {
48 // Tuple of [PromiseForTaskResult, taskStartTimestamp].
49 const pendingTasks = new Map<any, [Promise<Result>, number]>();
50 const logTrace = logger.bind(null, "collapsed-task-creator", "trace");
51
52 return async (...args: Args) => {
53 const taskKey = key(args);
54 const res = pendingTasks.get(taskKey);
55 const now = Date.now();
56 logTrace("requested = new state for taskKey/args", { args, taskKey });
57
58 if (!res || now - res[1] > collapseTasksMs) {
59 logTrace(
60 res
61 ? "started new task; there _was_ an in-progress one, but it's too old"
62 : "started new task b/c there was no in-progress task for these args",
63 args,
64 );
65
66 const taskRes = taskCreator(...args).finally(async () => {
67 // Only remove this task from pendingTasks if pendingTasks[taskKey]
68 // is still the same task. (It could be a new one if the old one was
69 // overwritten for taking longer than collapseTasksMs.)
70 const pendingValueNow = pendingTasks.get(taskKey);
71 if (pendingValueNow && pendingValueNow[0] === taskRes) {
72 logTrace("completed = new state for taskKey/args", { args, taskKey });
73 pendingTasks.delete(taskKey);
74 }
75 });
76
77 // Save the new task as a pending task. This will be _replacing_
78 // an existing pending task for this key if the other was too old.
79 pendingTasks.set(taskKey, [taskRes, now]);
80 logTrace("pending = new state for taskKey/args", { args, taskKey });
81 return taskRes;
82 }
83
84 logTrace(
85 "reusing result from prior, still-in-progress run of task for args/taskKey",
86 { args, taskKey },
87 );
88 return res[0];
89 };
90}