Atomic Locks
This guide covers atomic locks in AdonisJS applications. You will learn how to:
- Install and configure the
@adonisjs/lockpackage - Create and release locks to protect critical sections
- Use different acquisition methods for various scenarios
- Extend lock expiry for long-running operations
- Share locks between different processes
Overview
Atomic locks prevent race conditions when multiple processes or parts of your codebase might perform concurrent actions on the same resource. Consider a payment processing scenario where a queue job could be enqueued twice due to a network retry. Without proper locking, the system might charge the user twice. Atomic locks ensure that only one process can execute the critical section at a time.
The @adonisjs/lock package is a wrapper over
Verrou, a framework-agnostic locking library created and maintained by the AdonisJS core team. It supports three storage backends: Redis, database, and memory.
Installation
Install and configure the package using the following command.
node ace add @adonisjs/lock
See steps performed by the add command
-
Installs the
@adonisjs/lockpackage using the detected package manager. -
Registers the following service provider inside the
adonisrc.tsfile.adonisrc.ts{ providers: [ // ...other providers () => import('@adonisjs/lock/lock_provider') ] } -
Creates the
config/lock.tsfile. -
Defines the
LOCK_STOREenvironment variable and its validation inside thestart/env.tsfile. -
Creates a database migration for the locks table (if using the database store).
Configuration
The configuration is stored in the config/lock.ts file.
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/lock'
const lockConfig = defineConfig({
default: env.get('LOCK_STORE'),
stores: {
/**
* Redis store to manage locks.
* Requires the @adonisjs/redis package.
*/
redis: stores.redis({}),
/**
* Database store to manage locks.
* Requires the @adonisjs/lucid package.
*/
database: stores.database({
tableName: 'locks'
}),
/**
* Memory store could be used during testing.
*/
memory: stores.memory()
},
})
export default lockConfig
declare module '@adonisjs/lock/types' {
export interface LockStoresList extends InferLockStores<typeof lockConfig> {}
}
Redis store
The redis store has a peer dependency on the @adonisjs/redis package. You must
configure the Redis package before using the Redis store.
The connectionName is a reference to the connection defined within the config/redis.ts file. If not defined, the default Redis connection is used.
{
redis: stores.redis({
connectionName: 'main',
}),
}
Database store
The database store has a peer dependency on the @adonisjs/lucid package. You must
configure Lucid before using the database store.
The connectionName is a reference to a database connection defined within the config/database.ts file. If not defined, the default database connection is used.
{
database: stores.database({
connectionName: 'postgres',
tableName: 'my_locks',
}),
}
The data is stored within the locks table. A migration for this table is automatically created during installation. However, if needed, you can manually create a migration with the following contents.
Migration file contents
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'locks'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('key', 255).notNullable().primary()
table.string('owner').notNullable()
table.bigint('expiration').unsigned().nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
Environment variables
The default store is configured using the LOCK_STORE environment variable.
LOCK_STORE=redis
Creating locks
Create a lock using the createLock method from the lock manager service. The method accepts a unique key that identifies the resource being locked and a TTL (time-to-live) that defines how long the lock remains valid.
import lockManager from '@adonisjs/lock/services/main'
const lock = lockManager.createLock('processing_payment:order:42', '30s')
The lock key should uniquely identify the resource being protected. A common pattern is to use a descriptive prefix followed by an identifier, such as processing_payment:order:${orderId} or sending_email:user:${userId}.
The TTL accepts either a time expression string (like '10s', '5m', or '1h') or a number in milliseconds. The TTL acts as a safety mechanism. If a process crashes while holding a lock, the lock will automatically expire after the TTL, preventing deadlocks.
Acquiring locks
The package provides several methods for acquiring locks, each suited to different scenarios.
Running code within a lock
The run method is the recommended way to execute code within a lock. It acquires the lock, executes your callback, and automatically releases the lock when the callback completes (or throws an error).
import lockManager from '@adonisjs/lock/services/main'
export default class PaymentService {
async processPayment(order: Order) {
const lock = lockManager.createLock(
`processing_payment:order:${order.id}`,
'30s'
)
const [acquired, result] = await lock.run(async () => {
/**
* This callback only executes after acquiring the lock.
* The lock is automatically released when the callback
* completes or throws an error.
*/
const charge = await this.chargeCustomer(order)
await order.merge({ status: 'paid', chargeId: charge.id }).save()
return charge
})
if (!acquired) {
return { success: false, message: 'Payment already in progress' }
}
return { success: true, charge: result }
}
}
By default, run waits indefinitely until the lock becomes available. You can configure this behavior using options.
Running immediately or not at all
The runImmediately method attempts to acquire the lock without waiting. If the lock is already held by another process, the callback does not execute.
const [acquired, result] = await lock.runImmediately(async () => {
// Only runs if lock was acquired immediately
})
if (!acquired) {
// Lock was not available
}
Manual lock management
For more control over the lock lifecycle, use the acquire and release methods directly. When using manual acquisition, you must ensure the lock is released, even if an error occurs.
const acquired = await lock.acquire()
if (acquired) {
try {
// Perform protected operations
} finally {
await lock.release()
}
}
The acquireImmediately method attempts to acquire the lock without waiting and returns true if successful.
const acquired = await lock.acquireImmediately()
if (!acquired) {
// Lock was not available, handle accordingly
}
Prefer the run method over manual acquisition when possible. It handles lock release automatically, including when exceptions occur, which prevents accidental lock leaks.
Lock options
When acquiring a lock, you can configure retry behavior and timeouts.
timeout
Maximum time to wait before giving up on acquiring the lock. Accepts a time expression string or milliseconds.
await lock.acquire({ timeout: '5s' })
attempts
Maximum number of retry attempts before throwing an error.
await lock.acquire({ attempts: 3 })
delay
Delay between retry attempts. Accepts a time expression string or milliseconds.
await lock.acquire({ delay: '100ms' })
You can combine these options for fine-grained control.
const acquired = await lock.acquire({
timeout: '10s',
attempts: 5,
delay: '500ms'
})
Checking lock state
You can inspect the current state of a lock using the following methods.
const lock = lockManager.createLock('my_resource', '30s')
/**
* Check if the lock is currently held by any process.
*/
const locked = await lock.isLocked()
/**
* Check if the lock has expired.
*/
const expired = await lock.isExpired()
/**
* Get the remaining time in milliseconds before the lock expires.
* Returns null if the lock is not held.
*/
const remaining = await lock.getRemainingTime()
Extending locks
For long-running operations that might exceed the initial TTL, you can extend the lock duration. This is useful when you cannot predict exactly how long an operation will take.
const lock = lockManager.createLock('long_running_task', '10s')
await lock.acquire()
try {
for (const item of largeDataset) {
await processItem(item)
/**
* Extend the lock by another 10 seconds to prevent
* expiration during processing.
*/
await lock.extend('10s')
}
} finally {
await lock.release()
}
Sharing locks between processes
In distributed systems, you might need to acquire a lock in one process and release it in another. The serialize method converts a lock into a string that can be stored and restored elsewhere.
import lockManager from '@adonisjs/lock/services/main'
const lock = lockManager.createLock('batch_job:123', '5m')
await lock.acquire()
/**
* Serialize the lock for storage. This string contains
* all information needed to restore the lock.
*/
const serialized = lock.serialize()
/**
* Store the serialized lock (e.g., in a database or cache)
* for retrieval by another process.
*/
await redis.set('batch_job:123:lock', serialized)
In another process, restore the lock using restoreLock.
import lockManager from '@adonisjs/lock/services/main'
const serialized = await redis.get('batch_job:123:lock')
/**
* Restore the lock from its serialized form.
* This creates a lock instance with the same owner,
* allowing this process to release it.
*/
const lock = lockManager.restoreLock(serialized)
try {
// Complete the processing
} finally {
await lock.release()
}
See also
- Verrou documentation for advanced features and detailed API reference
- Redis for setting up the Redis store
- Lucid ORM for setting up the database store