1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";
6import * as Queue from "effect/Queue";
7import * as Ref from "effect/Ref";
8import * as Scope from "effect/Scope";
9
10import * as KeyedLock from "./KeyedLock.ts";
11
12describe("KeyedLock", () => {
13 it.effect("runs one holder of a key at a time, waiters in the order they queued", () =>
14 Effect.gen(function* () {
15 const lock = yield* KeyedLock.make<string>();
16 const events = yield* Ref.make<ReadonlyArray<string>>([]);
17 const record = (event: string) => Ref.update(events, (current) => [...current, event]);
18 const firstEntered = yield* Deferred.make<void>();
19 const releaseFirst = yield* Deferred.make<void>();
20
21 const first = yield* lock
22 .withLock(
23 "key",
24 record("first:start").pipe(
25 Effect.andThen(Deferred.succeed(firstEntered, undefined)),
26 Effect.andThen(Deferred.await(releaseFirst)),
27 Effect.andThen(record("first:end")),
28 ),
29 )
30 .pipe(Effect.forkChild);
31 yield* Deferred.await(firstEntered);
32 const second = yield* lock.withLock("key", record("second")).pipe(Effect.forkChild);
33 const third = yield* lock.withLock("key", record("third")).pipe(Effect.forkChild);
34 yield* Effect.yieldNow;
35 assert.deepStrictEqual(yield* Ref.get(events), ["first:start"]);
36
37 yield* Deferred.succeed(releaseFirst, undefined);
38 yield* Fiber.joinAll([first, second, third]);
39 assert.deepStrictEqual(yield* Ref.get(events), [
40 "first:start",
41 "first:end",
42 "second",
43 "third",
44 ]);
45 }),
46 );
47
48 it.effect("runs holders of different keys concurrently", () =>
49 Effect.gen(function* () {
50 const lock = yield* KeyedLock.make<string>();
51 const entered = yield* Queue.unbounded<string>();
52 const release = yield* Deferred.make<void>();
53 const hold = (key: string) =>
54 lock.withLock(key, Queue.offer(entered, key).pipe(Effect.andThen(Deferred.await(release))));
55
56 const holders = yield* Effect.forEach(["a", "b"], hold, { concurrency: "unbounded" }).pipe(
57 Effect.forkChild,
58 );
59 // Both enter while the other still holds its key.
60 const both = new Set([yield* Queue.take(entered), yield* Queue.take(entered)]);
61 assert.deepStrictEqual(both, new Set(["a", "b"]));
62 yield* Deferred.succeed(release, undefined);
63 yield* Fiber.join(holders);
64 }),
65 );
66
67 it.effect("keeps no lock for a key once its holders and waiters are gone", () =>
68 Effect.gen(function* () {
69 const lock = yield* KeyedLock.make<string>();
70 const entered = yield* Deferred.make<void>();
71 const release = yield* Deferred.make<void>();
72
73 const holder = yield* lock
74 .withLock(
75 "key",
76 Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
77 )
78 .pipe(Effect.forkChild);
79 yield* Deferred.await(entered);
80 // A waiter that gives up and one that runs.
81 const abandoned = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild);
82 const waiting = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild);
83 yield* Effect.yieldNow;
84 assert.deepStrictEqual(yield* lock.activeKeys, ["key"]);
85
86 yield* Fiber.interrupt(abandoned);
87 // The holder and the other waiter still need the lock.
88 assert.deepStrictEqual(yield* lock.activeKeys, ["key"]);
89 yield* Deferred.succeed(release, undefined);
90 yield* Fiber.joinAll([holder, waiting]);
91 // A holder that fails releases its key too.
92 assert.strictEqual(
93 yield* lock.withLock("other", Effect.fail("boom")).pipe(Effect.flip),
94 "boom",
95 );
96
97 assert.deepStrictEqual(yield* lock.activeKeys, []);
98 }),
99 );
100
101 it.effect("keeps working after the scope that made it closes", () =>
102 Effect.gen(function* () {
103 // Services built per request hand their lock to work that outlives the build.
104 const lock = yield* Effect.scoped(KeyedLock.make<string>());
105 assert.strictEqual(yield* lock.withLock("key", Effect.succeed("ran")), "ran");
106 }),
107 );
108
109 it.effect("runs the effect in the caller's scope", () =>
110 Effect.gen(function* () {
111 const lock = yield* KeyedLock.make<string>();
112 const finalized = yield* Ref.make(false);
113 const callerScope = yield* Scope.make();
114
115 yield* lock
116 .withLock(
117 "key",
118 Effect.addFinalizer(() => Ref.set(finalized, true)),
119 )
120 .pipe(Scope.provide(callerScope));
121 // The finalizer belongs to the caller, so releasing the lock leaves it.
122 assert.isFalse(yield* Ref.get(finalized));
123 yield* Scope.close(callerScope, Exit.void);
124 assert.isTrue(yield* Ref.get(finalized));
125 }),
126 );
127});