Skip to content

Lock

Contract

@modularityjs/lock defines the abstract LockService:

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

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

typescript
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

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

typescript
LockRedisModule.forRoot({ keyNamespace: 'myapp-lock:' });
OptionDefaultDescription
keyNamespace'lock:'Prefix appended after the global Redis key prefix

Retry Pattern

A reusable helper that retries lock acquisition with exponential backoff:

typescript
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

FeatureMemoryRedis
ScopeSingle processDistributed
TTL enforcementChecked at acquire timeAtomic via PX
Crash recoveryLock lost with processExpires after TTL
Safe releaseDirect deleteLua script checks owner token