@kontsedal/locco
v0.1.0
Published
Distributed locks library
Downloads
4,415
Maintainers
Readme
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.