felanios/murlock
MurLock: A distributed locking solution for NestJS, providing a decorator for critical sections with Redis-based synchronization. Ideal for microservices and scalable applications.
MurLock is a distributed lock solution designed for the NestJS framework. It provides a decorator @MurLock() that allows for critical sections of your application to be locked to prevent race conditions. MurLock uses Redis to ensure locks are respected across multiple instances of your application, making it perfect for microservices.
MurLock has a peer dependency on @nestjs/common and reflect-metadata. These should already be installed in your NestJS project. In addition, you'll also need to install the redis package.
npm install --save murlock redis reflect-metadata
MurLock is primarily used through the @MurLock() decorator.
First, you need to import the MurLockModule and set it up in your module using forRoot. This method is used for global configuration that can be reused across different parts of your application.
import { MurLockModule } from 'murlock';
@Module({
imports: [
MurLockModule.forRoot({
redisOptions: { url: 'redis://localhost:6379' },
wait: 1000,
maxAttempts: 3,
logLevel: 'log',
ignoreUnlockFail: false,
failFastOnRedisError: false,
blocking: false,
}),
],
})
export class AppModule {}
Then, you can use in your services:
No download data available
No tracked packages depend on this.
@MurLock()import { MurLock } from 'murlock';
@Injectable()
export class AppService {
@MurLock(5000, 'user.id')
async someFunction(user: User): Promise<void> {
// Some critical section that only one request should be able to execute at a time
}
}
By default, if there is single wrapped parameter, the property of parameter can be called directly as it shown.
import { MurLock } from 'murlock';
@Injectable()
export class AppService {
@MurLock(5000, 'userId')
async someFunction({
userId,
firstName,
lastName,
}: {
userId: string;
firstName: string;
lastName: string;
}): Promise<void> {
// Some critical section that only one request should be able to execute at a time
}
}
If there are multiple wrapped parameter, you can call it by {index of parameter}.{parameter name} as it shown
import { MurLock } from 'murlock';
@Injectable()
export class AppService {
@MurLock(5000, '0.userId', '1.transactionId')
async someFunction(
{ userId, firstName, lastName }: UserDTO,
{ balance, transactionId }: TransactionDTO
): Promise<void> {
// Some critical section that only one request should be able to execute at a time
}
}
In the example above, the @MurLock() decorator will prevent someFunction() from being executed concurrently for the same user. If another request comes in for the same user before someFunction() has finished executing, it will wait up to 5000 milliseconds (5 seconds) for the lock to be released. If the lock is not released within this time, an MurLockException will be thrown.
The parameters to @MurLock() are a release time (in milliseconds), followed by any number of key parameters. The key parameters are used to create a unique key for each lock. They should be properties of the parameters of the method. In the example above, 'user.id' is used, which means the lock key will be different for each user ID.
MurLock also supports async configuration. This can be useful if your Redis configuration is not known at compile time.
import { MurLockModule } from 'murlock';
@Module({
imports: [
MurLockModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
redisOptions: configService.get('REDIS_OPTIONS'),
wait: configService.get('MURLOCK_WAIT'),
maxAttempts: configService.get('MURLOCK_MAX_ATTEMPTS'),
logLevel: configService.get('LOG_LEVEL'),
ignoreUnlockFail: configService.get('IGNORE_UNLOCK_FAIL'),
failFastOnRedisError: configService.get('FAIL_FAST_ON_REDIS_ERROR'),
blocking: configService.get('BLOCKING_MODE'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
In the example above, the ConfigModule and ConfigService are used to provide the configuration for MurLock asynchronously.
You can override the global wait parameter per decorator, allowing fine-grained retry control:
@MurLock(
5000,
(retries) => (Math.floor(Math.random() * 50) + 50) * retries,
'user.id',
)
async someFunction(user: User): Promise<void> {
// This uses a randomized backoff strategy for retrying lock acquisition.
}
@MurLock(5000, 1500, 'user.id')
async anotherFunction(user: User): Promise<void> {
// This will retry every 1500ms instead of global wait.
}
When using @MurLock with other decorators (such as @Transactional from typeorm-transactional), you may encounter issues with parameter name extraction if the other decorator wraps the method before @MurLock is applied.
TypeScript decorators execute in bottom-up order. If another decorator wraps the method before @MurLock, the parameter names cannot be extracted from the wrapped function:
// This may fail if @Transactional wraps the method before @MurLock executes
@MurLock(5000, 'userData.id')
@Transactional()
async process(userData: { id: string }, options: string[] = []): Promise<any> {
// Error: Parameter userData not found in method arguments
}
Use the SetParamNames decorator to explicitly specify parameter names. Important: SetParamNames must be placed below @MurLock in the code (it will execute before @MurLock due to TypeScript's bottom-up decorator execution order).
The object format allows you to specify only the parameters you need, with explicit index mapping. This provides O(1) lookup performance and doesn't require specifying all parameters:
import { MurLock, SetParamNames } from 'murlock';
class MyService {
@MurLock(5000, 'userData.id')
@SetParamNames({ userData: 0 }) // Only specify needed params with their indices
@Transactional()
async process(
userData: { id: string },
options: string[],
context: any
): Promise<any> {
// This will work correctly - only userData is needed for the lock key
}
}
Benefits of Object Format:
// Example: Only need the third parameter for the lock key
@MurLock(5000, 'context.tenantId')
@SetParamNames({ context: 2 }) // context is at index 2
@Transactional()
async process(userData: any, options: any, context: { tenantId: string }): Promise<any> {
// ...
}
The array format requires specifying ALL parameters in order:
import { MurLock, SetParamNames } from 'murlock';
class MyService {
@MurLock(5000, 'userData.id')
@SetParamNames('userData', 'options') // Must specify ALL params in order
@Transactional()
async process(
userData: { id: string },
options: string[] = []
): Promise<any> {
// This will work correctly
}
}
Note: The array format uses
indexOfto find parameters, so all parameters must be specified in the correct order. If you only need one parameter, the object format is recommended.
Decorator Execution Order (bottom-up):
@Transactional() executes first and wraps the method@SetParamNames executes second and stores parameter names in metadata@MurLock executes last and reads parameter names from metadataIf you place @SetParamNames above @MurLock, it will execute after @MurLock, and the metadata won't be available when @MurLock needs it.
As a workaround, you can use parameter indices instead of names:
@MurLock(5000, '0.id') // Uses index 0 for userData
@Transactional()
async process(userData: { id: string }, options: string[] = []): Promise<any> {
// This works, but name-based keys are more readable
}
SetParamNames stores parameter names in metadata before the method is wrapped@MurLock reads the parameter names from metadata when the function is already wrappedMurLock supports a blocking mode where it will continuously retry to acquire the lock until successful. This is useful for critical operations that must eventually succeed.
@Module({
imports: [
MurLockModule.forRoot({
redisOptions: { url: 'redis://localhost:6379' },
wait: 1000,
maxAttempts: 3,
logLevel: 'log',
blocking: true, // Enable blocking mode
}),
],
})
export class AppModule {}
When blocking mode is enabled:
maxAttempts parameter is ignoredwait timeMurLock includes robust Redis connection handling:
@Module({
imports: [
MurLockModule.forRoot({
redisOptions: {
url: 'redis://localhost:6379',
socket: {
keepAlive: false,
reconnectStrategy: (retries) => {
const delay = Math.min(retries * 500, 5000);
return delay;
},
},
},
failFastOnRedisError: true, // Exit application on Redis connection failure
}),
],
})
export class AppModule {}
By default, murlock use class and method name prefix for example Userservice:createUser:{userId}. By setting lockKeyPrefix as 'custom' you can define by yourself manually.
import { MurLockModule } from 'murlock';
@Module({
imports: [
MurLockModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
redisOptions: configService.get('REDIS_OPTIONS'),
wait: configService.get('MURLOCK_WAIT'),
maxAttempts: configService.get('MURLOCK_MAX_ATTEMPTS'),
logLevel: configService.get('LOG_LEVEL'),
lockKeyPrefix: 'custom',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
import { MurLock } from 'murlock';
@Injectable()
export class AppService {
@MurLock(5000, 'someCustomKey', 'userId')
async someFunction(userId): Promise<void> {
// Some critical section that only one request should be able to execute at a time
}
}
In some scenarios, throwing an exception when a lock cannot be released can be undesirable. For example, you might prefer to log the failure and continue without interrupting the flow of your application. To enable this behavior, set the ignoreUnlockFail option to true in your configuration:
import { MurLockModule } from 'murlock';
MurLockModule.forRoot({
redisOptions: { url: 'redis://localhost:6379' },
wait: 1000,
maxAttempts: 3,
logLevel: 'log',
ignoreUnlockFail: true, // Unlock failures will be logged instead of throwing exceptions.
lockKeyPrefix: 'default' // optional, use 'default' if you would like to lock keys as servicename:methodname:customdata, otherwise use 'custom' to manually write each lock key
}),
If we assume userId as 65782628 Lockey here will be someCustomKey:65782628
MurLockService DirectlyWhile the @MurLock() decorator provides a convenient and declarative way to handle locking within your NestJS application, there may be cases where you need more control over the lock lifecycle. For such cases, MurLockService offers a programmatic way to manage locks, allowing for fine-grained control over the lock and unlock process through the runWithLock method.
MurLockServiceFirst, inject MurLockService into your service:
import { Injectable } from '@nestjs/common';
import { MurLockService } from 'murlock';
@Injectable()
export class YourService {
constructor(private murLockService: MurLockService) {}
// Your methods where you want to use the lock
}
You no longer need to manually manage lock and unlock. Instead, use the runWithLock method, which handles both acquiring and releasing the lock:
async performTaskWithLock() {
const lockKey = 'unique_lock_key';
const lockTime = 3000; // Duration for which the lock should be held, in milliseconds
try {
await this.murLockService.runWithLock(lockKey, lockTime, async () => {
// Proceed with the operation that requires the lock
});
} catch (error) {
// Handle the error if the lock could not be acquired or any other exceptions
throw error;
}
}
The runWithLock method throws an exception if the lock cannot be acquired within the specified time or if an error occurs during the execution of the function:
try {
await this.murLockService.runWithLock(lockKey, lockTime, async () => {
// Locked operations
});
} catch (error) {
// Error handling logic
}
Directly using MurLockService gives you finer control over lock management but also increases the responsibility to ensure locks are correctly managed throughout your application's lifecycle.
ignoreUnlockFail set to true, implement error handling strategies to log and manage unlock failures, ensuring they do not disrupt the application flow.logLevel based on your environment. Use 'debug' for development and 'error' or 'warn' for production.lockKeyPrefix to tailor how lock keys are constructed:
Userservice:createUser:{userId}.lockKeyPrefix to 'custom' and define lock keys explicitly to fine-tune lock scope and granularity.runWithLock manages lock cleanup, ensure your application logic correctly handles any necessary cleanup or rollback in case of errors.finally block to ensure that locks are always released, preventing potential deadlocks and resource leaks.failFastOnRedisError in production to ensure application fails fast on Redis connection issuesA method decorator to indicate that a particular method should be locked.
releaseTime: Time in milliseconds after which the lock should be automatically released.wait (optional): Time in milliseconds to wait between retry attempts, or a function that calculates wait time based on retry count....keyParams: Method parameters based on which the lock should be made. The format is paramName.attribute. If just paramName is provided, it will use the toString method of that parameter.Here are the customizable options for MurLockModule, allowing you to tailor its behavior to best fit your application's needs:
true, the module will not throw an exception if releasing a lock fails. This setting helps in scenarios where failing silently is preferred over interrupting the application flow. Defaults to false to ensure that failures are noticed and handled appropriately.Userservice:createUser:{userId}.true, the application will exit with code 1 if a Redis connection error occurs. Defaults to false.true, the lock acquisition will retry indefinitely until successful. Defaults to false.A NestJS injectable service to interact with the locking mechanism directly.
We welcome contributions! Please see our contributing guide for more information. For support, raise an issue on our GitHub repository.
This project is licensed under the MIT License.
If you have any questions or feedback, feel free to contact me at ozmen.eyupfurkan@gmail.com.
We hope you find MurLock useful in your projects. Don't forget to star our repo if you find it helpful!
Happy coding! 🚀