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

acquire is a template method on the contract: it validates ttlMs (rejecting a non-positive or non-integer value with a ValidationException) before delegating to the driver, so every driver agrees — the memory and Redis drivers can't diverge on, say, ttlMs: 0.

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({ url: 'redis://localhost:6379' }),
  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

@WithLock (lock-plugins)

@modularityjs/lock-plugins adds the method decorator, so the acquire/run/release dance above does not have to be written by hand at every call site:

typescript
import { WithLock } from '@modularityjs/lock-plugins';

@Injectable()
class PayoutService {
  @WithLock({ ttlMs: 30_000, key: (args) => `payout:${String(args[0])}` })
  async settle(accountId: string): Promise<void> {
    /* exactly one instance runs this per account */
  }
}

Wire LockPluginsModule alongside PluginsModule, LockModule, and a driver. Contention throws ConflictException rather than waiting, ttlMs is required (no default lease), and release happens on both the return and the throw path. It rides the shared method-interceptor seam at order 350 — outside @Transactional so the lease still covers COMMIT, inside @WithCircuitBreaker so an open circuit refuses before a lock is taken. Full option table and the ordering rationale are in the plugins guide.