<!-- Markdown mirror of https://justscale.sh/docs/fundamentals/locks -->

# Distributed Locks

Prevent race conditions with type-safe distributed locking

Locks provide distributed mutual exclusion for persistent entities. A `Lock` 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

```typescript
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

```typescript
{
  using user = await lockService.acquire(existingUser);
  // ... do work with exclusive access ...
} // Lock released automatically here
```

## Requiring Lock Proof

The real power of `Lock` is requiring it in function signatures. This prevents deadlocks from nested acquisition and makes lock requirements explicit:

payment-service.tsTypeScript

```typescript
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 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

```typescript
// 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

```typescript
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` you held before calling `lock()` is discarded; the `Locked` 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

```typescript
// 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`, 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 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 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 can exist. Locked is scope-bound via Symbol.dispose — the moment the lock releases, the brand goes with it. You cannot store a Locked 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`, 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

```typescript
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

```typescript
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

```typescript
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?

Allows natural chaining without intermediate variables:

promise-chaining.tsTypeScript

```typescript
// 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}`));
```

## Next Steps

- Repositories Overview
- Testing
