distributed-lock/README.md
2026-01-30 11:55:48 -08:00

6.9 KiB
Raw Permalink Blame History

@lilith/distributed-lock

Redis-based distributed locking with atomic Lua scripts for high-performance, reliable mutual exclusion across processes and servers.

Features

  • Token-based ownership: Only the lock holder can release or extend the lock
  • Automatic expiration: Prevents deadlocks with configurable TTL
  • Retry with exponential backoff: Configurable retry logic for contended resources
  • Atomic operations: All Redis operations use Lua scripts for atomicity
  • Type-safe: Full TypeScript support with comprehensive type definitions
  • Lock extension: Extend lock duration for long-running operations

Installation

pnpm add @lilith/distributed-lock ioredis

Basic Usage

import { Redis } from 'ioredis'
import { DistributedLock } from '@lilith/distributed-lock'

const redis = new Redis()
const lock = new DistributedLock(redis, {
  keyPrefix: 'myapp:',
  defaultTtlMs: 5000,
  maxRetries: 3,
  retryDelayMs: 100,
})

// Acquire lock with automatic retry
const handle = await lock.acquire('user:123')
try {
  // Critical section - only one process can execute this
  await processUser(123)
} finally {
  await handle.release()
}

API

DistributedLock

Constructor

new DistributedLock(redis: Redis, options?: LockOptions)

Options:

  • keyPrefix (default: 'lock:'): Prefix for all lock keys in Redis
  • defaultTtlMs (default: 30000): Default lock TTL in milliseconds
  • retryDelayMs (default: 100): Base delay between retry attempts
  • maxRetries (default: 0): Maximum retry attempts (0 = no retry)

Methods

acquire(key: string, ttlMs?: number): Promise<LockHandle>

Acquire a lock, throwing if unable to acquire after retries.

const handle = await lock.acquire('resource-id', 10000)
tryAcquire(key: string, ttlMs?: number): Promise<LockHandle | null>

Try to acquire a lock without retrying. Returns null if lock is held.

const handle = await lock.tryAcquire('resource-id')
if (!handle) {
  console.log('Resource is busy')
  return
}
waitForLock(key: string, timeoutMs: number, ttlMs?: number): Promise<LockHandle>

Wait for a lock to become available, with timeout.

// Wait up to 5 seconds for lock
const handle = await lock.waitForLock('resource-id', 5000)
isLocked(key: string): Promise<boolean>

Check if a lock is currently held.

if (await lock.isLocked('resource-id')) {
  console.log('Resource is locked')
}

LockHandle

Returned when a lock is successfully acquired.

Methods

release(): Promise<void>

Release the lock immediately.

await handle.release()
extend(additionalMs: number): Promise<void>

Extend the lock's TTL by the specified duration.

await handle.extend(5000) // Add 5 more seconds

Properties

isHeld: boolean

Check if the lock is currently held (local state check).

info: LockInfo

Get detailed information about the lock.

const { key, token, acquiredAt, expiresAt, isHeld } = handle.info

Usage Patterns

Simple Lock

const handle = await lock.acquire('user:123')
try {
  await updateUser(123)
} finally {
  await handle.release()
}

Non-Blocking Try

const handle = await lock.tryAcquire('user:123')
if (!handle) {
  return { error: 'Resource is busy, try again later' }
}

try {
  await updateUser(123)
} finally {
  await handle.release()
}

Wait with Timeout

try {
  const handle = await lock.waitForLock('user:123', 5000)
  try {
    await updateUser(123)
  } finally {
    await handle.release()
  }
} catch (error) {
  console.log('Timeout waiting for lock')
}

Long-Running Operations

const handle = await lock.acquire('export:123', 60000) // 60s initial TTL

try {
  for (const batch of exportBatches) {
    await processBatch(batch)

    // Extend lock if we need more time
    await handle.extend(30000)
  }
} finally {
  await handle.release()
}

Check Lock Status

if (await lock.isLocked('migration:active')) {
  console.log('Migration is running, skipping')
  return
}

const handle = await lock.acquire('migration:active', 300000) // 5 minutes
try {
  await runMigration()
} finally {
  await handle.release()
}

Error Handling

LockAcquisitionError

Thrown when unable to acquire lock after retries or timeout.

import { LockAcquisitionError } from '@lilith/distributed-lock'

try {
  const handle = await lock.acquire('resource')
} catch (error) {
  if (error instanceof LockAcquisitionError) {
    console.log(`Failed to acquire lock: ${error.key}`)
    console.log(`Attempts: ${error.retriesAttempted}`)
  }
}

InvalidLockError

Thrown when attempting to operate on a released or expired lock.

import { InvalidLockError } from '@lilith/distributed-lock'

try {
  await handle.release()
  await handle.extend(5000) // This will throw
} catch (error) {
  if (error instanceof InvalidLockError) {
    console.log('Lock is no longer held')
  }
}

Implementation Details

Atomicity

All Redis operations use Lua scripts to ensure atomicity:

  • ACQUIRE_LOCK_SCRIPT: Checks existence and sets key with TTL atomically
  • RELEASE_LOCK_SCRIPT: Verifies token and deletes key atomically
  • EXTEND_LOCK_SCRIPT: Verifies token and extends TTL atomically
  • CHECK_LOCK_SCRIPT: Gets TTL atomically

Token-Based Ownership

Each lock acquisition generates a unique token (UUID v4). Only the holder of the token can:

  • Release the lock
  • Extend the lock

This prevents accidental release by other processes.

Automatic Expiration

All locks have a TTL (time-to-live) and automatically expire. This prevents deadlocks if:

  • A process crashes while holding a lock
  • A process forgets to release a lock
  • Network partitions occur

Exponential Backoff

When maxRetries > 0, the lock manager uses exponential backoff:

  • Attempt 1: Base delay
  • Attempt 2: Base delay × 2
  • Attempt 3: Base delay × 4
  • etc.

This reduces contention on highly contended resources.

Best Practices

  1. Always release in finally: Use try/finally to ensure locks are released
  2. Set appropriate TTLs: TTL should be longer than expected operation time
  3. Use tryAcquire for optional operations: Don't block if work can be deferred
  4. Extend locks for long operations: Better than setting very long TTLs
  5. Handle errors gracefully: Catch LockAcquisitionError and InvalidLockError
  6. Use descriptive keys: Include resource type and ID (user:123, export:456)

Performance

  • Acquire: 1 Redis command (EVAL with Lua script)
  • Release: 1 Redis command (EVAL with Lua script)
  • Extend: 1 Redis command (EVAL with Lua script)
  • Check: 1 Redis command (EVAL with Lua script)

All operations complete in O(1) time.

License

MIT

Contributing

Part of the Lilith Platform. See project guidelines.