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

    0.0.2 • 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 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.

    Install

    npm i @kontsedal/locco

    DownloadsWeekly Downloads

    159

    Version

    0.0.2

    License

    MIT

    Unpacked Size

    102 kB

    Total Files

    68

    Last publish

    Collaborators

    • kontsedal