Lock
Contract
@modularityjs/lock defines the abstract LockService:
interface LockAcquireOptions {
readonly ttlMs: number;
}
abstract class LockService {
abstract acquire(
key: string,
options: LockAcquireOptions,
): Promise<string | undefined>;
abstract release(key: string, token: string): Promise<void>;
}acquire returns the owner token on success (opaque UUID) or undefined on contention. Callers must pass the token back to release — the token proves ownership, preventing a process that doesn't hold the lock from releasing it. Drivers verify the token matches the stored lock before deleting it (via a Lua CAS script on Redis).
Drivers
Memory (@modularityjs/lock-memory)
Single-process in-memory lock. For development and testing.
import { LockModule } from '@modularityjs/lock';
import { LockMemoryModule } from '@modularityjs/lock-memory';
const modules = [LockModule, LockMemoryModule];Redis (@modularityjs/lock-redis)
Distributed lock using SET NX PX with Lua-based safe release (owner token pattern). Requires @modularityjs/redis.
import { LockModule } from '@modularityjs/lock';
import { LockRedisModule } from '@modularityjs/lock-redis';
import { RedisModule } from '@modularityjs/redis';
const modules = [
RedisModule.forRoot({ host: 'localhost' }),
LockModule,
LockRedisModule,
];Usage
import { Inject, Injectable } from '@modularityjs/di';
import { LockService } from '@modularityjs/lock';
@Injectable()
class PaymentService {
constructor(@Inject(LockService) private readonly lock: LockService) {}
async processPayment(orderId: string): Promise<void> {
const token = await this.lock.acquire(`payment:${orderId}`, {
ttlMs: 30_000,
});
if (!token) {
throw new Error('Payment already being processed');
}
try {
// critical section
} finally {
await this.lock.release(`payment:${orderId}`, token);
}
}
}Configuration
The Redis driver supports key namespacing. The key namespace is appended after the global Redis key prefix.
LockRedisModule.forRoot({ keyNamespace: 'myapp-lock:' });| Option | Default | Description |
|---|---|---|
keyNamespace | 'lock:' | Prefix appended after the global Redis key prefix |
Retry Pattern
A reusable helper that retries lock acquisition with exponential backoff:
async function withLock(
lock: LockService,
key: string,
ttlMs: number,
fn: () => Promise<void>,
maxRetries = 5,
baseDelayMs = 100,
): Promise<void> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const token = await lock.acquire(key, { ttlMs });
if (token) {
try {
await fn();
return;
} finally {
await lock.release(key, token);
}
}
if (attempt < maxRetries) {
const delay = baseDelayMs * 2 ** attempt;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error(
`Failed to acquire lock "${key}" after ${maxRetries + 1} attempts`,
);
}TTL Strategy
TTL should be 2-3x the expected critical section duration. If the TTL is too short, the lock expires while work is still running, causing concurrent access. If it is too long and the holder crashes, other processes wait unnecessarily before the lock expires. Start with a generous TTL and tune it based on observed durations.
Memory vs Redis
| Feature | Memory | Redis |
|---|---|---|
| Scope | Single process | Distributed |
| TTL enforcement | Checked at acquire time | Atomic via PX |
| Crash recovery | Lock lost with process | Expires after TTL |
| Safe release | Direct delete | Lua script checks owner token |