Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/kontsedal/locco

A node.js locks library with support of Redis and MongoDB
https://github.com/kontsedal/locco

distributed-locks mongodb nodejs redis redlock synchronization

Last synced: about 1 month ago
JSON representation

A node.js locks library with support of Redis and MongoDB

Awesome Lists containing this project

README

        

[![Buuild and Test](https://github.com/kontsedal/locco/workflows/Build%20and%20Test/badge.svg)](https://github.com/kontsedal/locco/actions/workflows/status.yml?query=branch%3Amain++)
![Coverage Badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Kontsedal/e0ad01840d30efd4c1766e5ba5845567/raw/bf778a4aa5262997514945e024aa6722d5f72016/locco__heads_main.json)

# 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

```shell
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:

```typescript
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:

```typescript
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:

```typescript
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\_

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\_

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\_

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

##### _Lock.isLocked() => Promise\_

Checks if a lock is still valid

##### _Lock.setRetrySettings(settings: RetrySettings) => Promise\_

Overrides a default retry settings of the lock.

---

### Redis adapter

Requires only a compatible with ioredis client:

```typescript
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:

```typescript
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:

```typescript
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.