Search code examples
typescriptconcurrencylocking

Trying to implement a simple resource locking system in TypeScript - short fiddle with test case included


I have a server that receives requests concurrently but some R/W resources cannot be accessed at the same time and it is not practical for me to implement the lock at database level, so I want to create a function that turns parallel requests into sequential ones.

The idea is to have queues with each an individual resource id, and when the request is received, if the queue corresponding to the resource id is empty it executes the function I want, if not it just queues the function to be executed after the other ones have finished.

I came up with some code that is not working and I don't get why.

Here is the example fiddle:

export const sleep = (ms: number): Promise<void> =>
  new Promise((resolve) => { setTimeout(resolve, ms); });

type AsyncFn = () => Promise<void>;

const locks = new Map<string, AsyncFn[]>();

const unstackLock = async (id: string) => {
  const stack = locks.get(id);

  if (!stack) {
    return;
  }

  const nextFn = stack.shift();

  if (!nextFn) {
    return;
  }

  try {
    await nextFn();
  } finally {
    await unstackLock(id);
  }
};

export const withLock = async (id: string, fn: AsyncFn): Promise<void> => {
  if (!locks.has(id)) {
    locks.set(id, []);
  }

  const lock = locks.get(id);

  if (!lock) {
    // never happens but makes TS happy
    throw new Error('Lock is not defined');
  }

  lock.push(fn);

  if (lock.length === 1) {
    await unstackLock(id);
  }
};

const test = async () => {
  const results: number[] = [];
    
  const f1 = withLock('lock', async () => {
    await sleep(Math.random() * 100);
    results.push(1);
  });

  const f2 = withLock('lock', async () => {
    await sleep(Math.random() * 100);
    results.push(2);
  });

  const f3 = withLock('lock', async () => {
    await sleep(Math.random() * 100);
    results.push(3);
  });

  await Promise.all([f1, f2, f3]);

  if (results[0] !== 1 || results[1] !== 2 || results[2] !== 3) {
    console.log('FAILED', results);
  } else {
    console.log('SUCCESS');
  }
};

test();

I want the results array to be [1, 2, 3] however long f1, f2 and f3 (which respectively push 1, 2, and 3 to the resultsarray) take to execute, but it is in random order.


Solution

  • OK I came up with a version that works:

    export const sleep = (ms: number): Promise<void> =>
      new Promise((resolve) => { setTimeout(resolve, ms); });
    
    type AsyncFn = () => Promise<void>;
    
    type Lock = {
      running: Promise<void> | null;
      queue: AsyncFn[];
    }
    
    const locks = new Map<string, Lock>();
    
    const unstackLock = async (id: string) => {
      const stack = locks.get(id);
    
      if (!stack) {
        return;
      }
    
      if (stack.queue.length === 0) {
        return;
      }
    
      if (!stack.running) {
        const fn = stack.queue.shift();
        if (fn) {
          stack.running = fn();
          await stack.running;
          stack.running = null;
          await unstackLock(id);
        }
      }
    };
    
    export const withLock = async (id: string, fn: AsyncFn): Promise<void> => {
      if (!locks.has(id)) {
        locks.set(id, { running: null, queue: [] });
      }
    
      const lock = locks.get(id);
    
      if (!lock) {
        // never happens but makes TS happy
        throw new Error('Lock is not defined');
      }
    
      lock.queue.push(fn);
    
      return unstackLock(id);
    };
    
    const test = async () => {
      const results: number[] = [];
        
        const f1 = withLock('lock', async () => {
        await sleep(Math.random() * 100);
        results.push(1);
      });
    
      const f2 = withLock('lock', async () => {
        await sleep(Math.random() * 100);
        results.push(2);
      });
    
      const f3 = withLock('lock', async () => {
        await sleep(Math.random() * 100);
        results.push(3);
      });
    
      await Promise.all([f1, f2, f3]);
    
      if (results[0] !== 1 || results[1] !== 2 || results[2] !== 3) {
        console.log('FAILED', results);
      } else {
        console.log('SUCCESS');
      }
    };
    
    test();