Skip to content

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.get(User.ref`${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/core/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.get(User.ref`${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.get(User.ref`${id}`);
user.name = 'Bob';
await userRepo.save(user);  // Type error!

// Must lock first
using user = await lockService.acquire(userRepo.get(User.ref`${id}`));
user.name = 'Bob';
await userRepo.save(user);  // OK - user is Lock<Persistent<User>>

// 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 get(ref: Ref<T>): 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
}

Lock Is a Fresh Read

Acquiring a lock is not just a mutex operation — it is an atomic lock-and-read. Whatever Persistent<T> you held before calling lock() is discarded; the Locked<T> you get back is built from a row that was just re-read under the lock. There is no separate SELECT followed by an acquire — it is one statement.

The Postgres adapter implements this with a single SELECT ... FOR UPDATE:

pg-repository-lock.tsTypeScript
// packages/adapters/postgres/src/repository/pg-repository.ts
async lock(entity: Ref<T>): Promise<Locked<T> | null> {
  const id = extractId(entity)

  // Row-level lock + fresh read in one statement.
  // No other session can modify this row until we release.
  const result = await sql`
    SELECT * FROM ${sql(this.tableName)} WHERE id = ${id} FOR UPDATE
  `
  if (result.length === 0) return null

  // The Locked<T> is built from the POST-lock row — not
  // from anything the caller passed in.
  const fresh = this.rowToEntity(result[0])
  return brandLocked(fresh)
}
💡

Tip

The invariant: every time you hold a Locked<T>, its contents are authoritative as of the moment the lock was acquired. No other process can have modified it since, because no other session can hold the lock concurrently.

What this eliminates

  • No optimistic-concurrency retries.There is no version column, no CAS loop, no "save failed, re-fetch and try again" ceremony. If you hold the lock, your read is current and your write will apply.
  • Read-before-lock is not a footgun. A common pattern is to findOne an entity, decide based on its state whether to mutate, then lock it. That is safe: the initial read serves as an ID carrier and a cheap branch; the Locked<T> you operate on came from the post-lock re-read, which may have different contents than what you first saw.
  • Caches cannot poison mutation paths. You can cache Persistent<T> freely — the moment you need to mutate, you call lock(), which re-reads. The cache only ever affects read paths; the write path always sees fresh data.
  • No stale Locked<T> can exist. Locked<T> is scope-bound via Symbol.dispose — the moment the lock releases, the brand goes with it. You cannot store a Locked<T> in a long-lived cache; the type system forbids smuggling it past its using scope.

The consequence

Combined with the type-level requirement that every mutating repository method takes Locked<T>, this means: in a JustScale app, every write happens on data that was re-read atomically with the lock that protects it.Stale-write bugs — the kind that usually force frameworks to ship version columns, retry loops, or "last writer wins" warnings in the docs — are structurally impossible under the repo API.

Setup

Add a lock feature to the app builder. It provides the abstract LockService that services can inject.

src/app.tsTypeScript
import JustScale from '@justscale/core';
import { InMemoryLockFeature } from '@justscale/core/memory';
import { defaultHttpConfig } from '@justscale/http/testing';
// import { UserController } from './controllers/user';

const app = JustScale()
  .add(defaultHttpConfig)
  .add(InMemoryLockFeature)   // provides AbstractLockProvider
  // .add(UserController)
  .build();

await app.serve();

Use PostgresLockFeature from @justscale/postgres for multi-node deployments.

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

InMemoryLockFeature from @justscale/core/memory — single-node apps, development, and tests. All locks live in process memory; nothing crosses node boundaries.

Postgres (advisory locks)

PostgresLockFeature from @justscale/postgres — distributed locking via PostgreSQL advisory locks. Same LockService interface, but locks are held against the database and release automatically if the session dies.

Redis (Coming Soon)

Redis-based locking with the Redlock algorithm — on the roadmap.

Double-Lock Detection

Trying to acquire a lock you already hold in the same async context throws DoubleLockError. This prevents silent deadlocks from re-entrant acquisition — e.g. a helper function that locks an entity already locked by its caller, or a process handler that receives a signal carrying an entity reference and then tries to lock it again.

double-lock.tsTypeScript
using a = await repo.lock(orderRef)
using b = await repo.lock(orderRef)
// → DoubleLockError: Cannot acquire lock "lock:Order:abc"
//                    — already held in this async context.

// Fix: pass the existing Lock<T> to the helper, don't re-lock
async function charge(order: Locked<Order>, amount: string) {
  await repo.update(order, { charged: amount })
}

using order = await repo.lock(orderRef)
await charge(order, '100.00')  // No re-lock — type proves ownership

Tracking is scoped to the current async context via AsyncLocalStorage: separate requests and separate process executions each get their own held-locks set, so concurrent workers don't false-positive on each other.

Inside durable process handlers a DoubleLockError is treated as recoverable — the process stays suspended at the prior step with a lastError marker instead of transitioning to failed, so a fix-and-redeploy can rescue the in-flight state. See the Processes Runtime page for details.

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.get(User.ref`${id}`);
using lockedUser = await lockService.acquire(user);

// Just:
using user = await lockService.acquire(userRepo.get(User.ref`${id}`));