@kontsedal/locco
TypeScript icon, indicating that this package has built-in type declarations

0.1.0 • Public • Published

Buuild and Test Coverage Badge

locco

A small and simple library to deal with race conditions in distributed systems by applying locks on resources. Currently, supports locking via Redis, MongoDB, and in-memory object.

Installation

npm i @kontsedal/locco

Core logic

With locks, user can just say "I'm doing some stuff with this user, please lock him and don't allow anybody to change him" and no one will, till a lock is valid.

The core logic is simple. When we create a lock we generate a unique string identifying a current lock operation. Then, we search for a valid lock with a same key in the storage(Redis, Mongo, js object) and if it doesn't exist we add one and proceed. If a valid lock already exists we retry this operation for some time and then fail.

When we release or extend a lock, we check that lock exists in the storage and has the same unique identifier with a current lock. It makes impossible to release or extend other process lock.

Usage

There are two ways to create a resource lock. In the first one, you should manually lock and unlock a resource. Here is an example with a Redis:

import { Locker, IoRedisAdapter } from "@kontsedal/locco";
import Redis from "ioredis";

const redisAdapter = new IoRedisAdapter({ client: new Redis() });
const locker = new Locker({
  adapter: redisAdapter,
  retrySettings: { retryDelay: 200, retryTimes: 10 },
});

const lock = await locker.lock("user:123", 3000).aquire();
try {
  //do some risky stuff here
  //...
  //
  await lock.extend(2000);
  //do more risky stuff
  //...
} catch (error) {
} finally {
  await lock.release();
}

In the second one, you pass a function in the acquire method and a lock will be released automatically when a function finishes. Here is an example with a mongo:

import { Locker, IoRedisAdapter, MongoAdapter } from "@kontsedal/Locker";
import { MongoClient } from "mongodb";

const mongoAdapter = new MongoAdapter({
  client: new MongoClient(process.env.MONGO_URL),
});
const locker = new Locker({
  adapter: mongoAdapter,
  retrySettings: { retryDelay: 200, retryTimes: 10 },
});

await locker.lock("user:123", 3000).setRetrySettings({retryDelay: 200, retryTimes: 50}).aquire(async (lock) => {
  //do some risky stuff here
  //...
  await lock.extend(2000);
  //do some risky stuff here
  //...
});

API

Locker

The main class is responsible for the creation of new locks and passing them a storage adapter and default retrySettings.

Constructor params:

parameter type isRequired description
params.adapter ILockAdapter true Adapter to work with a lock keys storage. Currently Redis, Mongo and in-memory adapters are implemented
params.retrySettings object true
params.retrySettings.retryTimes number(milliseconds) false How many times we should retry lock before fail
params.retrySettings.retryDelay number(milliseconds) false How much time should pass between retries
params.retrySettings.totalTime number(milliseconds) false How much time should all retries last in total
params.retrySettings.retryDelayFn function false Function which returns a retryDelay for each attempt. Allows to implement an own delay logic

Example of a retryDelayFn usage:

const locker = new Locker({
  adapter: new InMemoryAdapter(),
  retrySettings: {
    retryDelayFn: ({
      attemptNumber, // starts from 0
      startedAt, // date of start in milliseconds
      previousDelay,
      settings, // retrySettings
      stop, // function to stop a retries, throws an error
    }) => {
      if (attemptNumber === 4) {
        stop();
      }
      return (attemptNumber + 1) * 50;
    },
  },
});

Provided example will do the same as providing retryTimes = 5, retryDelay = 50

Methods

lock(key: string, ttl: number) => Lock

Creates a Lock instance with provided key and time to live in milliseconds. It won't lock a resource at this point. Need to call an aquire() to do so

Lock.aquire(cb?: (lock: Lock) => void) => Promise<Lock>

Locks a resource if possible. If not, it retries as much as specified in the retrySettings. If callback is provided, lock will be released after a callback execution.

Lock.release({ throwOnFail?: boolean }) => Promise<void>

Unlocks a resource. If a resource is invalid (already taken by other lock or expired) it won't throw an error. To make it throw an error, need to provide {throwOnFail:true}.

Lock.extend(ttl: number) => Promise<void>

Extends a lock for a provided milliseconds from now. Will throw an error if current lock is already invalid

Lock.isLocked() => Promise<boolean>

Checks if a lock is still valid

Lock.setRetrySettings(settings: RetrySettings) => Promise<Lock>

Overrides a default retry settings of the lock.


Redis adapter

Requires only a compatible with ioredis client:

import { IoRedisAdapter } from "@kontsedal/locco";
import Redis from "ioredis";

const redisAdapter = new IoRedisAdapter({ client: new Redis() });

How it works

It relies on a Redis SET command with options NX and PX.

NX - ensures that a record will be removed after provided time

PX - ensures that if a record already exists it won't be replaced with a new one

So, to create a lock we just execute a SET command and if it returns "OK" response means that lock is created, if it returns null - a resource is locked.

To release or extend a lock, firstly, it gets a current key value(which is a unique string for each lock) and compares it with a current one. If it matches we either remove the key or set a new TTL for it.


Mongo adapter

Requires a mongo client and optional database name and lock collection name:

import { MongoAdapter } from "@kontsedal/locco";
import { MongoClient } from "mongodb";

const mongoAdapter = new MongoAdapter({
  client: new MongoClient(process.env.MONGO_URL),
  dbName: "my-db", // optional parameter
  locksCollectionName: "locks", //optional parameter, defaults to "locco-locks"
});

How it works

We create a collection of locks in the database with the next fields:

  • key: string
  • uniqueValue: string
  • expireAt: Date

For this collection we create a special index { key: 1 }, { unique: true }, so mongo will throw an error if we try to create a new record with an existing key.

To create a lock, we use an updateOne method with an upsert = true option:

collection.updateOne(
  {
    key,
    expireAt: { $lt: new Date() },
  },
  { $set: { key, uniqueValue, expireAt: new Date(Date.now() + ttl) } },
  { upsert: true }
);

So, let's imagine that we want to create a lock and there is a valid lock in the DB. If the lock is valid, it won't pass expireAt: { $lt: new Date() } check, because its expireAt will be later than a current date. In this case updateOne will try to create a new record in the collection, because of { upsert: true } option. But it will throw an error because we have a unique index. So this operation can only be successful when there is no valid lock in the DB. If there is an invalid lock in the DB, it will be replaced by a new one.

Release and extend relies on the same logic, but we also compare with a key unique string.

Package Sidebar

Install

npm i @kontsedal/locco

Weekly Downloads

127

Version

0.1.0

License

MIT

Unpacked Size

68.2 kB

Total Files

41

Last publish

Collaborators

  • kontsedal