Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 90 lines 4.5 kB view raw
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}