@studnicky/mutex
Key-based async mutex for preventing race conditions in concurrent operations.
Install
pnpm add @studnicky/mutexUsage
Acquire a lock on a named key with runExclusive. Different keys run concurrently; the same key serializes:
import { Mutex } from '../src/index.js';
const mutex = Mutex.create<string>();
// Different keys run concurrently — track completion order
const completionOrder: string[] = [];
class KeyedMutexDemo {
static async runParallelKeys(): Promise<void> {
// Two concurrent runExclusive calls on DIFFERENT keys should both start immediately
await Promise.all([
mutex.runExclusive('keyA', () => {
completionOrder.push('keyA');
}),
mutex.runExclusive('keyB', () => {
completionOrder.push('keyB');
})
]);
console.log('Completion order (parallel keys):', completionOrder);
}
static async runSerialSameKey(): Promise<void> {
// Concurrent calls on the SAME key must serialize
let counter = 0;
const results: number[] = [];
await Promise.all([
mutex.runExclusive('shared', () => {
const snapshot = counter;
counter++;
results.push(snapshot);
}),
mutex.runExclusive('shared', () => {
const snapshot = counter;
counter++;
results.push(snapshot);
}),
mutex.runExclusive('shared', () => {
const snapshot = counter;
counter++;
results.push(snapshot);
})
]);
console.log('Serialized counter:', counter);
console.log('Serialized results:', results.toSorted((a, b) => { const result = a - b; return result; }));
}
static showStats(): void {
const stats = mutex.getStats();
console.log('Stats:', stats);
}
}
await KeyedMutexDemo.runParallelKeys();
await KeyedMutexDemo.runSerialSameKey();
KeyedMutexDemo.showStats();Manual acquire/release
Use acquire() when you need explicit try/finally control, or acquireDisposable() for a releaseable handle:
import { Mutex } from '../src/index.js';
const mutex = Mutex.create<string>();
class AcquireReleaseDemo {
static async runManualRelease(): Promise<void> {
const release = await mutex.acquire('resource');
// Queue a second waiter without awaiting — it will sit in the queue
let secondAcquired = false;
const secondPromise = mutex.acquire('resource').then((rel) => {
secondAcquired = true;
rel();
});
// The second waiter is in the queue
console.log('Queue size while locked:', mutex.queueSize('resource'));
try {
// Critical section — 'resource' is exclusively held here
console.log('Is locked:', mutex.isLocked('resource'));
} finally {
release();
}
// Wait for the queued waiter to complete
await secondPromise;
console.log('Second acquired:', secondAcquired);
console.log('Is locked after all released:', mutex.isLocked('resource'));
console.log('Queue size after completion:', mutex.queueSize('resource'));
}
static async runDisposable(): Promise<void> {
// acquireDisposable — manual release via .release()
const lock = await mutex.acquireDisposable('disposable');
console.log('Lock key:', lock.key);
console.log('Is locked (disposable):', mutex.isLocked('disposable'));
lock.release();
console.log('Is locked after release:', mutex.isLocked('disposable'));
}
}
await AcquireReleaseDemo.runManualRelease();
await AcquireReleaseDemo.runDisposable();Try it
The hooks demo subclasses Mutex and overrides eight protected lifecycle methods. Observe the trace: beforeAcquire fires for every caller regardless of contention; onContended fires only for the queued waiter; onAcquireWait fires only after the waiter acquires through the queue; and onQueueDrain fires once the key's queue empties.
Public API
Import Mutex, MutexCreateOptionsInterface, its schema-backed entities, package errors, MutexInterface, and MutexLockInterface from @studnicky/mutex. Create instances directly with Mutex.create(config?); implementation constants remain internal.
Pass a ClockProviderInterface through clock when application timing needs to be deterministic. Mutex uses that provider for acquisition waits and lock-hold measurements; the default is RealTimeClockProvider.
Observability hooks
Subclass Mutex and override any protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained by the base class so lock acquisition and release semantics still win.
| Hook | When it fires | Args |
|---|---|---|
beforeAcquire(key) | Before any acquisition attempt (immediate or queued) | key: K |
afterAcquire(key, waitTimeMs) | After lock is granted (both immediate and queued paths) | key: K, waitTimeMs: number |
onAcquireWait(key, waitTimeMs) | After a queued waiter finally acquires the lock (never fires for immediate grants) | key: K, waitTimeMs: number |
onContended(key, queueSize) | When a caller finds the lock held and enqueues itself | key: K, queueSize: number (depth before enqueue) |
onRelease(key) | On every lock release by its holder, before any handoff or drop | key: K |
beforeRelease(key, holdTimeMs) | Before a lock is released, with hold time | key: K, holdTimeMs: number |
afterRelease(key) | After the lock is dropped completely (no waiters remained) | key: K |
onQueueDrain(key) | When the last waiter for a key leaves the queue (by acquiring or timing out) | key: K |
onTimeout(key, timeoutMs) | When a queued acquisition exceeds the configured timeout | key: K, timeoutMs: number |
onEnterKey(key, to, from) | On every per-key FSM state transition | key: K, to: MutexKeyStateEntity.Type, from: MutexKeyStateEntity.Type |
import type { AcquireWaitEventEntity, QueueDrainEventEntity, ReleaseEventEntity } from '../src/entities/index.js';
import { Mutex } from '../src/index.js';
class TracingMutex extends Mutex<string> {
readonly acquireWaitEvents: AcquireWaitEventEntity.Type[] = [];
readonly queueDrainEvents: QueueDrainEventEntity.Type[] = [];
readonly releaseEvents: ReleaseEventEntity.Type[] = [];
protected override beforeAcquire(key: string): void {
console.log(`[mutex] beforeAcquire key=${key}`);
}
protected override afterAcquire(key: string, waitTimeMs: number): void {
console.log(`[mutex] afterAcquire key=${key} waitTimeMs=${waitTimeMs}`);
}
protected override onAcquireWait(key: string, waitTimeMs: number): void {
console.log(`[mutex] onAcquireWait key=${key} waitTimeMs=${waitTimeMs}`);
this.acquireWaitEvents.push({ 'key': key, 'waitTimeMs': waitTimeMs });
}
protected override onContended(key: string, queueSize: number): void {
console.log(`[mutex] onContended key=${key} queueSize=${queueSize}`);
}
protected override onRelease(key: string): void {
console.log(`[mutex] onRelease key=${key}`);
this.releaseEvents.push({ 'key': key });
}
protected override beforeRelease(key: string, holdTimeMs: number): void {
console.log(`[mutex] beforeRelease key=${key} holdTimeMs=${holdTimeMs}`);
}
protected override afterRelease(key: string): void {
console.log(`[mutex] afterRelease key=${key}`);
}
protected override onQueueDrain(key: string): void {
console.log(`[mutex] onQueueDrain key=${key}`);
this.queueDrainEvents.push({ 'key': key });
}
protected override onTimeout(key: string, timeoutMs: number): void {
console.log(`[mutex] onTimeout key=${key} timeoutMs=${timeoutMs}`);
}
}
// Runs the contention scenario end-to-end so the module ends up with a single
// top-level `mutex` binding instead of one per intermediate step.
class MutexDemoRunner {
static async run(mutex: TracingMutex): Promise<void> {
// Caller 1 grabs the lock
const release1 = await mutex.acquire('account-42');
// Caller 2 enqueues (runs concurrently, not awaited yet)
const pending2 = mutex.acquire('account-42');
// Simulate holder doing work
await new Promise<void>((resolve) => { setTimeout(resolve, 10); });
// Release — hands lock to caller 2
release1();
// Caller 2 now gets the lock
const release2 = await pending2;
release2();
// Caller 3 — different key, immediate acquisition, no contention
const accountBalances: number[] = [];
await mutex.runExclusive('account-99', () => {
accountBalances.push(42);
});
}
}
const mutex = TracingMutex.create();
await MutexDemoRunner.run(mutex);The base class never calls any logger or metrics library. All hooks are no-ops by default.
Entities
@studnicky/mutex/entities exports every schema namespace in src/entities.
import { MutexConfigEntity } from '@studnicky/mutex/entities';Interfaces
@studnicky/mutex/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { MutexInterface } from '@studnicky/mutex/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Mutex | Provides mutex functionality. | @studnicky/mutex |
MutexCreateOptionsInterface | Accepts mutex settings and the optional Clock provider. | @studnicky/mutex |
LockTimeoutError | Represents lock timeout failures. | @studnicky/mutex |
MutexError | Represents mutex failures. | @studnicky/mutex |
QueueSizeExceededError | Represents queue size exceeded failures. | @studnicky/mutex |