1import * as Effect from "effect/Effect";
2import * as Ref from "effect/Ref";
3import * as Semaphore from "effect/Semaphore";
4
5export interface KeyedLock<Key> {
6 /** Runs `effect` once no other holder of `key` is running. Queued waiters go first-in, first-out. */
7 readonly withLock: <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
8 /** The keys someone holds or waits on right now. */
9 readonly activeKeys: Effect.Effect<ReadonlyArray<Key>>;
10}
11
12interface LockEntry {
13 readonly semaphore: Semaphore.Semaphore;
14 readonly users: number;
15}
16
17/**
18 * Mutual exclusion per key. A key's lock exists only while someone holds or
19 * waits on it, so locks for keys that come and go never accumulate. Not
20 * reentrant: taking a key while already holding it deadlocks.
21 *
22 * Keys compare like `Map` keys (by value for strings and numbers). The lock is
23 * not tied to a scope, so it keeps working for as long as anyone references it.
24 */
25export const make = <Key>(): Effect.Effect<KeyedLock<Key>> =>
26 Effect.gen(function* () {
27 const locks = yield* Ref.make<ReadonlyMap<Key, LockEntry>>(new Map());
28
29 const acquire = (key: Key) =>
30 Effect.flatMap(Semaphore.make(1), (candidate) =>
31 Ref.modify(locks, (current) => {
32 const existing = current.get(key);
33 const semaphore = existing?.semaphore ?? candidate;
34 const next = new Map(current);
35 next.set(key, { semaphore, users: (existing?.users ?? 0) + 1 });
36 return [semaphore, next] as const;
37 }),
38 );
39
40 const release = (key: Key) =>
41 Ref.update(locks, (current) => {
42 const existing = current.get(key);
43 if (existing === undefined) return current;
44 const next = new Map(current);
45 if (existing.users === 1) {
46 next.delete(key);
47 } else {
48 next.set(key, { ...existing, users: existing.users - 1 });
49 }
50 return next;
51 });
52
53 return {
54 withLock: (key, effect) =>
55 Effect.acquireUseRelease(
56 acquire(key),
57 (semaphore) => semaphore.withPermit(effect),
58 () => release(key),
59 ),
60 activeKeys: Effect.map(Ref.get(locks), (current) => Array.from(current.keys())),
61 } satisfies KeyedLock<Key>;
62 });