| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Ref from "effect/Ref"; |
| 3 | import * as Semaphore from "effect/Semaphore"; |
| 4 | |
| 5 | export 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 | |
| 12 | interface 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 | */ |
| 25 | export 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 | }); |