Question

Race condition when using Lodash's _.once function

For example, take the following code:

const fetch = _.once(myRealFetch)
const queue = new PQueue({concurrency: 1000});

queue.add(function() {
  const result = fetch()
  // Rest of the code ...
})
queue.add(function() {
  const result = fetch()
  // Rest of the code ...
})

await queue.start().onIdle(); 

It declares a fetch function which is memoized to run once using Lodash's _.once function.

I then continue to create a PQueue, add 2 functions that invoke fetch into the queue, and run it.

Now let's assume myRealFetch takes 5s to run, since I run it with concurrency > 1, that means one of the 2 functions will invoke it firstly, and most likely, the second function will invoke it as well.

At this point, the first invocation hasn't finished, so what will be returned in the second invocation?

Is there a builtin way to handle that?

 2  41  2
1 Jan 1970

Solution

 2

You can test this yourself with something like the code below. Here is a JSFiddle you can play with to see what happens.

I've added few logs with timestamps, as well as adding another item to the queue after the first 2 tasks are completed (around 6s).

import { once } from 'https://esm.run/lodash-es';
import PQueue from 'https://esm.run/p-queue';
import moment from 'https://esm.run/moment';

const time = moment()

function logWithTime(msg) {
  console.log(`[T + ${moment().diff(time, 'ms')}ms]`, msg)
}

const wait = (t) => async () => new Promise((resolve) => {
  logWithTime(`Waiting for ${t / 1000}s...`);

  setTimeout(() => {
    logWithTime(`Done - Waited for ${t / 1000}s`);

    resolve('some result');
  }, t);
});

const fetch = once(wait(5000))
const queue = new PQueue({ concurrency: 1000 });

queue.add(async function() {
  logWithTime('adding 1')

  const result = await fetch()
  logWithTime('add1 - result')
  logWithTime(result)
})

queue.add(async function() {
  logWithTime('adding 2')

  const result = await fetch()
  logWithTime('add2 - result')
  logWithTime(result)
})

setTimeout(() => {
  logWithTime('adding 3')

  queue.add(async function() {
    const result = await fetch()
    logWithTime('add3 - result')
    logWithTime(result)
  })
}, 6000)

(async () => {
  await queue.start().onIdle();
})()

Resulting output of this would be...

[T + 0ms] adding 1
[T + 0ms] Waiting for 5s...
[T + 0ms] adding 2
[T + 5001ms] Done - Waited for 5s
[T + 5002ms] add1 - result
[T + 5002ms] some result
[T + 5002ms] add2 - result
[T + 5002ms] some result
[T + 6002ms] adding 3
[T + 6002ms] add3 - result
[T + 6002ms] some result

You can see that the first 2 are resolved around 5s almost immediately after each other, thus the fetch is only called once. This is also evidenced by the logs within the wait function only being called once.

Then the 3rd task that is added after the first 2 have resolved, is immediately returned without calling the fetch (i.e. wait) function. Thus the _.once is applied and any other calls to the fetch method are immediately returned with the original memoized result.

2024-07-19
Nickofthyme