@frozor/lock
v1.2.1
Published
Concurrency control primitives for async JavaScript: Lock, MultiLock, and Semaphore
Maintainers
Readme
@frozor/lock
Concurrency control primitives for async JavaScript/TypeScript: Lock, MultiLock, and Semaphore.
Installation
npm install @frozor/lockAPI
Lock
A mutual exclusion lock. Only one acquire call runs at a time; others queue up in FIFO order.
import { Lock } from '@frozor/lock';
const lock = new Lock();
// Only one of these will run at a time
await Promise.all([
lock.acquire(async () => {
await writeToFile('hello');
}),
lock.acquire(async () => {
await writeToFile('world');
}),
]);
// Check how many callers are waiting
console.log(lock.queueLength);MultiLock<TKey = string>
Manages independent keyed locks. Locks are created on demand and cleaned up
after their active work and queue drain, including when work throws.
Cleanup is complete when acquire settles; signalling from inside a callback
does not by itself mean that the acquisition has finished.
import { MultiLock } from '@frozor/lock';
const locks = new MultiLock<string>();
// These two calls run concurrently (different IDs)
await Promise.all([
locks.acquire('user-1', () => updateUser('user-1')),
locks.acquire('user-2', () => updateUser('user-2')),
]);
// These two calls run sequentially (same ID)
await Promise.all([
locks.acquire('user-1', () => updateUser('user-1')),
locks.acquire('user-1', () => updateUser('user-1')),
]);
// Number of keys currently active or queued
console.log(locks.size);Keys use JavaScript Map identity semantics. To use object keys, provide the
key type explicitly; the same object reference shares a lock, while a different
object is a different key:
const locks = new MultiLock<object>();
const user = {};
await Promise.all([
locks.acquire(user, () => updateUser()),
locks.acquire(user, () => updateUser()),
]);Semaphore
Limits concurrency to a maximum number of simultaneous operations.
import { Semaphore } from '@frozor/lock';
// Allow up to 3 concurrent requests
const semaphore = new Semaphore(3);
const urls = [/* ... */];
await Promise.all(
urls.map(url =>
semaphore.acquire(() => fetch(url))
)
);Passing undefined (or no argument) disables the concurrency limit — acquire runs work immediately:
const semaphore = new Semaphore(); // no limit
await semaphore.acquire(() => doWork()); // runs immediatelysemaphore.queueLength reports callers still waiting for a permit, just like
lock.queueLength. Timed-out callers are removed from that queue.
Bounded acquisition
All three primitives accept an optional IAcquireOptions argument:
await lock.acquire(() => updateFile(), { timeoutMs: 5000 });
await locks.acquire('user-1', () => updateUser('user-1'), { timeoutMs: 5000 });
await semaphore.acquire(() => fetch(url), { timeoutMs: 5000 });timeoutMs limits waiting for a lock or permit, not how long the work can run:
- Omit it (or pass
undefined) for the existing unbounded waiting behavior. - Use
0to acquire immediately if available, or reject without queueing. - Other values must be finite, nonnegative numbers; invalid options reject with
RangeErrorbefore invoking work, even when no concurrency limit is set. - Values above
2 ** 31 - 1milliseconds (about 24.9 days) are capped at that native timer limit.AcquisitionTimeoutError.timeoutMsreports the effective, normalized budget. - When waiting expires, the acquisition rejects with
AcquisitionTimeoutErrorand its callback is never invoked. Other live waiters keep their FIFO order. - Once a permit is granted, its timeout is cleared. Even if its continuation starts after the deadline, granted work runs and owns the permit until it settles.
import { AcquisitionTimeoutError, Lock } from '@frozor/lock';
const lock = new Lock();
try {
await lock.acquire(() => updateFile(), { timeoutMs: 5000 });
} catch (error) {
if (error instanceof AcquisitionTimeoutError) {
console.log(`Could not acquire within ${error.timeoutMs} ms`);
} else {
throw error; // Preserve failures from the work itself.
}
}Deadlines use a monotonic clock. Timeout notification depends on event-loop
scheduling; granting also checks the deadline so a delayed timer cannot admit
expired work. The timeout budget is normalized once when acquire is called,
before creating the deadline or scheduling timers.
A waiting timeout cannot interrupt running work or recover a stuck holder.
Cancellation of the work itself remains the caller's responsibility.
MultiLock keeps an active key while its timed-out waiters are removed, and
releases the key after all remaining callers finish.
MaybePromise<T>
Utility type used by all acquire methods. Your work callback can return either T or Promise<T>.
import type { MaybePromise } from '@frozor/lock';License
MIT
