|
Some checks failed
Build and Publish / build-and-publish (push) Failing after 45s
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| .turbo | ||
| src | ||
| .gitignore | ||
| examples.md | ||
| IMPLEMENTATION_VERIFICATION.md | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
@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 RedisdefaultTtlMs(default:30000): Default lock TTL in millisecondsretryDelayMs(default:100): Base delay between retry attemptsmaxRetries(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
- Always release in finally: Use try/finally to ensure locks are released
- Set appropriate TTLs: TTL should be longer than expected operation time
- Use tryAcquire for optional operations: Don't block if work can be deferred
- Extend locks for long operations: Better than setting very long TTLs
- Handle errors gracefully: Catch
LockAcquisitionErrorandInvalidLockError - 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.