Fighting race conditions in JavaScript using the example of working with the cache

Consider the following problem. We need to make third party API calls that are considered expensive and therefore need to be cached in Redis. We are using modern NodeJS (versions 14+), and hence the async / await constructs.

First, let’s write a class wrapper over the API call, where the call itself will be emulated with a 2-second timeout.

class ApiWrapper {
    #callTimes = 0;

    async apiCall(payload) {
        return new Promise(resolve => {
            setTimeout(() => {
                this.#callTimes++;
                resolve(`success: ${payload}`)
            }, 2000);
        })
    }

    get callTimes() {
        return this.#callTimes;
    }
}

const run = async () => {
    const api = new ApiWrapper();

    // эмулируем параллельный вызов API 4 раза
    const prDirect = await Promise.all([api.apiCall('test'), api.apiCall('test'), api.apiCall('test'), api.apiCall('test')]);
    console.log(prDirect); // => ['success: test', 'success: test', 'success: test', 'success: test']
    console.log(apiCache.callTimes); // => 4
}

run();

I specifically added a callTimes call counter to the class – it shows how many times we have called the API method. In this example, we have 4 direct calls.

Now let’s add caching in Redis to the code. For this we will use the redis @ next package.

Code with cachedApiCall caching method
class ApiWrapper {
    #client;
    #callTimes = 0;

    constructor(url) {
        // создаем клиента Redis
        this.#client = createClient({
            url
        });
    }

    async init() {
        // Подключаемся к Redis
        await this.#client.connect();
    }

    async apiCall(payload) {
        return new Promise(resolve => {
            setTimeout(() => {
                this.#callTimes++;
                resolve(`success: ${payload}`)
            }, 2000);
        })
    }

    async cachedApiCall(payload) {
        let data = await this.#client.get(payload);
        if (data === null) {
            // cache for 5 minutes
            data = await this.apiCall(payload);
            await this.#client.set(payload, data, {
                EX: 60 * 5
            });
        }
        return data;
    }

    get callTimes() {
        return this.#callTimes;
    }
}

const run = async () => {
    const api = new ApiWrapper('redis://10.250.200.9:6379/6');
    await api.init();

    const prCached = await Promise.all([api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test')]);
    console.log(prCached); // => ['success: test', 'success: test', 'success: test', 'success: test']
    console.log(api.callTimes); // => 4
}

Despite the fact that we call cachedApiCall, our API call counter still shows the number 4. This is due to the way async / await works.

Let’s take a closer look at the caching method. I wrote it as if I were working with synchronous code.

    async cachedApiCall(payload) {
        // получаем данные из кеша
        let data = await this.#client.get(payload);
        // если данных нет, то вызываем API и кладем в кеш
        if (data === null) {
            // cache for 5 minutes
            data = await this.apiCall(payload);
            await this.#client.set(payload, data, {
                EX: 60 * 5
            });
        }
        return data;
    }

In the code, when calling asynchronous methods, I used await. Therefore, as soon as the execution of the first call to cachedApiCall reaches this line, it will be interrupted, and the next parallel call will start working (and we have 4 of them). This will happen with every await call. If our call to cachedApiCall was not called in parallel, then there would be no problem in this code. But with a parallel call, we ran into a race condition. All 4 calls compete within the method, and in the end we have 4 requests to get the cache value, 4 API calls, and 4 calls to set the cache value.

How can this problem be solved? You need to hide await somewhere, for example, in a nested function, and cache the call of the nested function in memory for the duration of the work by the main function.

It will look like this:

    async cachedApiCall(payload) {
        // тут мы спраятали прошлый код во вложенную функцию getOrSet
        const getOrSet = async payload => {
            let data = await this.#client.get(payload);
            if (data === null) {
                // cache for 5 minutes
                data = await this.apiCall(payload);
                await this.#client.set(payload, data, {
                    EX: 60 * 5
                });
            }
            return data;
        }
        // во временном кеше на время работы функции мы храним промисы
        // если находясь тут у нас есть значение в tempCache, то мы словили
        // параллельный вызов
        if (typeof this.#tempCache[payload] !== 'undefined')
            return this.#tempCache[payload];
        // конструкция try - finally нам позволяет почистить за собой при
        // любом исходе
        try {
            // помещаем во временный кеш промис
            this.#tempCache[payload] = getOrSet(payload);
            // используем await, чтобы все параллельные вызовы сюда зашли
            return await this.#tempCache[payload];
        } finally {
            delete this.#tempCache[payload];
        }
    }

Now, with a parallel call to cachedApiCall, everyone will receive the same promise, which means that calls to Redis and to our API will happen only 1 time.

Have you struggled with race conditions in your JavaScript projects and what approaches have you taken?

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *