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.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:
{
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/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:
// 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
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:
// 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
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
findOnean entity, decide based on its state whether to mutate, thenlockit. That is safe: the initial read serves as an ID carrier and a cheap branch; theLocked<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 calllock(), 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 viaSymbol.dispose— the moment the lock releases, the brand goes with it. You cannot store aLocked<T>in a long-lived cache; the type system forbids smuggling it past itsusingscope.
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.
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:
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.
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 ownershipTracking 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)
usingis cleaner syntax thanawait using
Why accept Promise<T>?
Allows natural chaining without intermediate variables:
// 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}`));