Skip to content

eblocha/composable-locks

Repository files navigation

Composable-Locks

Composable concurrency locks for Javascript.

This library provides a number of different lock types:

  • Basic
  • Read-Write
  • Re-Entrant
  • Keyed

Like the package name entails, you can compose these lock types to create a multi-featured lock.

This library can be used both in the browser or in node. The most exotic globals used are:

  • Promise (and Promise.resolve)
  • Map (get, set, delete)

Install

yarn add composable-locks

or

npm install composable-locks

When you do, and don't need this module

You may need this module if you need to read data from an external source (like a file or database), modify it, then write it back. If you don't lock the resource, you might get the following scenario:

const append = async () => {
  let data = await fs.promises.readFile("my-file", { encoding: "utf-8" });
  data += "\nnewline!";
  await fs.promises.writeFile("my-file", data, { encoding: "utf-8" });
};

append();
append();

This is a race condition, since the second call to append might have the original data in my-file. It then appends to stale data, and overwrites whatever the first call did.

You don't need this module to lock in-memory resources, like counters. Since Node is single-threaded, you will never have a race condition for memory.

You can't use this module to lock resources across node processes.

Basic Mutex

Starting with a basic mutex:

import { Mutex } from "composable-locks";

const mutex = new Mutex();

(async () => {
  const release = await mutex.acquire();
  // do some stuff...
  release();
})();

Read-Write Mutex

Using a read-write mutex, you can allow multiple readers to acquire the lock, where writers need exclusive access:

import { RWMutex, LockTypes } from "composable-locks";

const mutex = new RWMutex(() => new Mutex());

const read = async () => {
  const release = await mutex.acquire(LockTypes.READ);
  try {
    // do some stuff...
  } finally {
    release();
  }
};

const write = async () => {
  const release = await mutex.acquire(LockTypes.WRITE);
  try {
    // do some stuff...
  } finally {
    release();
  }
};

read();
read();
write();
read();

Read-Preferring Mode

By default, the read-write lock is write-preferring, which means it will not allow readers to "skip the line", and go before queued writers.

If you want to switch to read-preferring, you can do so with another argument to the constructor:

const mutex = new RWMutex(() => new Mutex(), true);

This will increase the concurrency capability for reads, but may starve writes, so use caution.

Re-Entrant Mutex

A re-entrant mutex can re-acquire the lock. For example, to allow a recursive function to traverse a graph and visit the same node multiple times.

import { ReentrantMutex, Mutex, Domain } from "composable-locks";

const lock = new ReentrantMutex(() => new Mutex());

const domain = new Domain();

const release1 = await lock.acquire(domain);
const release2 = await lock.acquire(domain);
release1();
release2();

Keyed Mutex

The keyed mutex provides a way to map keys to different locks.

import { KeyedMutex, Mutex } from "composable-locks";

const locks = new KeyedMutex(() => new Mutex());

const releaseFile1 = await locks.acquire("file1");
const releaseFile2 = await locks.acquire("file2");

// etc. You get the idea...

Key Resolver

When locking files, you may want to resolve the path to the file, to prevent relative paths from double-locking a file. You can pass a resolver function to the mutex:

import { KeyedMutex, Mutex } from "composable-locks";
import * as path from "path";

const lock = new KeyedMutex(
  () => new Mutex(),
  (key) => path.resolve(key)
);

// this will now deadlock
const releaseFile1 = await lock.acquire("./somedir/file");
const releaseFile2 = await lock.acquire("./somedir/../somedir/file");

Composing Mutexes

You can compose these different mutex types together to combine functionality. Want a keyed, read-write, reentrant mutex? Just combine the components!

import {
  KeyedMutex,
  ReentrantMutex,
  RWMutex,
  Mutex,
  LockTypes,
  Domain,
} from "composable-locks";

const lock = new ReentrantMutex(
  () => new KeyedMutex(() => new RWMutex(() => new Mutex()))
);

const domain = new Domain();

await lock.acquire(domain, key, LockTypes.READ);

Utility Functions

withPermissions

withPermissions can release multiple locks after a function returns.

import { withPermissions, Mutex, KeyedMutex } from "composable-locks";

const lock = new KeyedMutex(() => new Mutex());

const result = await withPermissions(
  [lock.acquire("fileA"), lock.acquire("fileB")],
  async () => {
    // do stuff with file A and B...
    return result;
  }
);