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:

lock-example.tsTypeScript
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:

automatic-release.tsTypeScript
{
  using user = await lockService.acquire(existingUser);
  // ... do work with exclusive access ...
} // Lock released automatically here

Requiring 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:

payment-service.tsTypeScript
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:

repository-locking.tsTypeScript
// ❌ 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

repository-signatures.tsTypeScript
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:

setup.tsTypeScript
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:

lock-options.tsTypeScript
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:

in-memory-provider.tsTypeScript
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 locks

Redis (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)
  • using is cleaner syntax than await using

Why accept Promise<T>?

Allows natural chaining without intermediate variables:

promise-chaining.tsTypeScript
// Instead of:
const user = await userRepo.findById(id);
using lockedUser = await lockService.acquire(user);

// Just:
using user = await lockService.acquire(userRepo.findById(id));