1

The Situation

I have an asynchronous function that takes ~X seconds to resolve. The duration depends on network latency so it is unpredictable. The function is triggered by user input (think "save current form values to the server")

I would like to prevent this function from running multiple times simultaneously.

I would also like to "queue" calls to the function for potentially calling when the time is right.

The Question

(A) Is there a term for this concept of a "patient" async function.

(B) Is there a tool (similar to debounce) that will take an async function and turn it into a "patient" one.

slifty
  • 13,062
  • 13
  • 71
  • 109
  • Why over-engineer? Have a promise allocated for this but not assigned, and have the function assign that promise a real value. Then as long as the promise hasn't resolved yet, don't reassign it on subsequent calls. As for a name: not really, it's basically you looking for a lock pattern that works for your use case. Lots of ways to do it, but promises have their resolution baked in so you might as well tap into that. – Mike 'Pomax' Kamermans Jan 14 '22 at 21:38
  • Appreciated! I did end up doing something like that, but it's more complex because I *do* need to call the "most recent" save request that was made during the waiting period. It's almost an inverse debounce situation. I admit I'm surprised no utility exists for this since saving frontend values to a server as users interact seems like a pretty common use case! – slifty Jan 14 '22 at 22:27
  • I have an NPM package that can do this, check `async-await-queue`, you have to set up a queue with concurrency of 1 and 0 minimum wait. It also supports priorities. – mmomtchev Jan 14 '22 at 22:57
  • Why would that make things more complex? `let mostRecent; function doSave() { if (!mostRecent) mostRecent = new Promise((resolve, reject) => { when you resolve, also set mostRecent to underfined });` And done? It's probably time to show actual code in your question, reduced to minimal form but _not_ reduced to an artificial use case, make sure it still mirrors what _you_ need to do. – Mike 'Pomax' Kamermans Jan 14 '22 at 23:58
  • See https://stackoverflow.com/a/70326711/1048572 or https://stackoverflow.com/a/64955723/1048572 for example. I have not come across a standard terminology for this. – Bergi Jan 15 '22 at 01:29
  • ` I do need to call the "most recent" save request that was made during the waiting period` - that sounds like cache. Is the name for the design pattern you are looking for is "caching"? – slebetman Jan 15 '22 at 05:24
  • @slifty, have you seen https://stackoverflow.com/a/33703794/3478010? The author calls it "batching". – Roamer-1888 Jan 16 '22 at 10:54
  • @Roamer-1888 [Batching](https://en.wikipedia.org/wiki/Batch_production) is something different though – Bergi Jan 16 '22 at 11:13
  • @Bergi, for sure, it needs a better name. Maybe "economising" or "metering" would be closer. – Roamer-1888 Jan 16 '22 at 11:27
  • @Roamer-1888 Why not simply "queue calls"? FWIW, [here](https://stackoverflow.com/a/70326711/1048572) I've called it `makeSequential`. – Bergi Jan 16 '22 at 11:30
  • @Bergi, although the question includes the word "queue", I don't think it's about queuing. "Prevent this function from running multiple times simultaneously" *could* be interpreted as requiring a queue but not necessarily. Depends on the usage case. – Roamer-1888 Jan 16 '22 at 11:57

2 Answers2

0

Below, makePatient wraps a given function in another that invokes it only after the chain of prior invocations is resolved.

function pause() {
  return new Promise(resolve => {
    setTimeout(resolve, 1000)
  })
}

function foo(str) {
  return pause().then(() => console.log(str));
}

function bar(str) {
  return pause().then(() => console.log(str));
}

function makePatient(fn) {
  let pending = Promise.resolve();

  return (...params) => {
    return pending = pending.then(() => fn(...params)).catch(console.log);
  }
}

let patientFoo = makePatient(foo);
let patientBar = makePatient(bar);

patientFoo('foo');
patientFoo('foo');
patientFoo('foo');

patientBar('bar');
patientBar('bar').finally(() => console.log("all the bars are closed"));
danh
  • 62,181
  • 10
  • 95
  • 136
  • What's the point of using an objects with an `fn.name` property? Just store the promise itself in the variable. – Bergi Jan 15 '22 at 11:03
  • This doesn't properly handle errors. If one `fn` rejects, all other calls to it are affected as well – Bergi Jan 15 '22 at 11:04
  • @Bergi, it was my second thought to have an object using `fn.name` to segregate promises by function, worrying exactly about preventing an error in functionA's chain from halting functionB's chain. (and wrongly thinking that distinct `finally`'s had to be at the ends of chains). I think you're supposing (probably right) that the OP would want subsequent calls to execute despite errors on prior calls. The edit simplifies with a single chain, plows past errors as the default, adds params, and demonstrates `finally`. – danh Jan 15 '22 at 15:40
  • Thanks for the update. The chains are already separated by the to calls to `makePatient` creating different closures with different `pending` variables, it still works in your current code. Regarding error handling, `patientFoo('not foo').catch(e => console.log)` does now not prevent the subsequent calls from functioning, but the `catch` handler is not executed since the returned promise is always fulfilled. – Bergi Jan 15 '22 at 16:17
0

This answer is based on the answer by @danh.

The function queuePromise takes a function that returns a promise. Whenever the queued function is called, it's promise is chained to the promise of the previous call.

The main difference between @danh's answer and this, is that the queued function can receive arguments, and the promise is a simple let variable.

const queuePromise = fn => {
  let pending = Promise.resolve()
  return (...args) => {
    const func = () => fn(...args)
  
    return pending = pending.then(func, func)
  }

}

const demo = name => id => {
  console.log(`started ${name} ${id}`)
  
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // example of a failed promise in the middle of the chain
      if(id === 2) reject(`failed ${name} ${id}`)
      else resolve(`ended ${name} ${id}`)
    }, 500)
  })
}

const patientFoo = queuePromise(demo('foo'))
patientFoo(1).then(console.log).catch(console.log)
patientFoo(2).then(console.log).catch(console.log)
patientFoo(3).then(console.log).catch(console.log)

const patientBar = queuePromise(demo('bar'))
patientBar(1).then(console.log).catch(console.log)
patientBar(2).then(console.log).catch(console.log)
patientBar(3).then(console.log).catch(console.log)
Ori Drori
  • 183,571
  • 29
  • 224
  • 209