Distributed Locks
Prevent race conditions with type-safe distributed locking
Locks provide distributed mutual exclusion for persistent entities. A Lock<T> is a branded type that proves exclusive access has been acquired, allowing functions to require lock proof as a parameter — preventing deadlocks from nested acquisition attempts.
Core Principle
Lock is always Persistent — you can only lock something that exists outside this instance. The lock type proves you have exclusive access, and functions can require this proof in their signatures.
Basic Usage
Use the using keyword to automatically release locks when done:
import { LockServiceDef } from '@justscale/core';
const lockService = container.resolve(LockServiceDef);
// Acquire lock on an existing entity
using user = await lockService.acquire(existingUser);
// Chain with repository call
using user = await lockService.acquire(userRepo.findById(id));
if (!user) throw new NotFoundError();
// With options
using user = await lockService.acquire(user, {
ttl: 60000, // Lock expires after 60s
timeout: 10000 // Wait up to 10s to acquire
});Automatic Release
Lock implements Disposable, so the using keyword automatically releases the lock when the scope ends:
{
using user = await lockService.acquire(existingUser);
// ... do work with exclusive access ...
} // Lock released automatically hereRequiring Lock Proof
The real power of Lock<T> is requiring it in function signatures. This prevents deadlocks from nested acquisition and makes lock requirements explicit:
import { Lock, Persistent } from '@justscale/models';
class PaymentService {
// Function REQUIRES lock proof — caller must acquire before calling
async processRefund(user: Lock<Persistent<User>>, amount: number) {
// Type proves caller owns the lock
// No nested locking needed = no deadlocks
user.balance += amount;
await this.userRepo.save(user);
}
}
// Usage:
using user = await lockService.acquire(userRepo.findById(id));
if (!user) throw new NotFoundError();
await paymentService.processRefund(user, 100); // Type-safe!Why This Pattern?
- Prevents deadlocks — If serviceA calls serviceB and both try to lock the same entity, you get a deadlock. By requiring Lock<T> as a parameter, locking happens at the top level and proof flows down.
- Self-documenting — Function signatures clearly show what requires exclusive access
- Compile-time safety — TypeScript prevents calling locked functions without a lock
Repository Integration
The Repository enforces lock requirements for write operations. You can't save or delete a persistent entity without locking it first:
// ❌ COMPILE ERROR — can't save a plain Persistent<T>
const user = await userRepo.findById(id);
user.name = 'Bob';
await userRepo.save(user); // Type error!
// ✅ CORRECT — must lock first
using user = await lockService.acquire(userRepo.findById(id));
user.name = 'Bob';
await userRepo.save(user); // OK - user is Lock<Persistent<User>>
// ✅ CORRECT — new entities don't need locks
await userRepo.save({ name: 'Alice', email: 'alice@example.com' });Repository Method Signatures
abstract class Repository<T extends Entity<TId>, TId = string> {
// Read operations — no lock required
abstract find(options?: FindOptions<T>): Promise<Persistent<T>[]>;
abstract findById(id: TId): Promise<Persistent<T> | null>;
abstract findOne(where: Partial<T>): Promise<Persistent<T> | null>;
// Write operations — require lock OR new entity
abstract save(entity: Transient<T> | Lock<Persistent<T>>): Promise<Persistent<T>>;
// Delete — requires lock
abstract delete(entity: Lock<Persistent<T>>): Promise<void>;
abstract deleteById(id: TId): Promise<void>; // No lock - idempotent
}Setup
Register the lock provider and service in your container:
import { Container, LockServiceDef, AbstractLockProvider } from '@justscale/core';
import { InMemoryLockProvider } from '@justscale/lock-memory';
const container = new Container();
// Register the lock provider
container.registerInstance(AbstractLockProvider, new InMemoryLockProvider());
// Register the lock service
container.register(LockServiceDef);
// Resolve and use
const lockService = container.resolve(LockServiceDef);Lock Options
Configure lock behavior with options:
interface LockOptions {
ttl?: number; // Time-to-live in ms (default: 30000)
timeout?: number; // Acquisition timeout in ms (default: 5000)
key?: string; // Custom lock key (default: derived from entity)
}
// Example with all options
using user = await lockService.acquire(existingUser, {
ttl: 60000, // Lock expires after 60 seconds
timeout: 10000, // Wait up to 10 seconds to acquire
key: 'user:123' // Custom key (usually auto-derived)
});- ttl — Safety net if release fails. Lock expires automatically after this time.
- timeout — How long to wait if another process holds the lock.
- key — Usually derived from entity ID. Custom keys for special cases.
Lock Providers
In-Memory (Default)
For single-instance applications, development, and testing:
import { InMemoryLockProvider } from '@justscale/lock-memory';
const provider = new InMemoryLockProvider();
// Testing helpers
provider.clear(); // Clear all locks
provider.isLocked(key); // Check if key is locked
provider.getLockedBy(key); // Get instance ID holding lock
provider.size; // Number of active locksRedis (Coming Soon)
For distributed applications, use Redis-based locking with Redlock algorithm.
Postgres (Coming Soon)
Advisory locks or table-based locking for Postgres applications.
Design Notes
Why using instead of await using?
Lock release is fire-and-forget. We don't need to await the unlock because:
- Lock has TTL — worst case it expires naturally
- Unlock failure is recoverable (just slower for next acquirer)
usingis cleaner syntax thanawait using
Why accept Promise<T>?
Allows natural chaining without intermediate variables:
// Instead of:
const user = await userRepo.findById(id);
using lockedUser = await lockService.acquire(user);
// Just:
using user = await lockService.acquire(userRepo.findById(id));