effect-distributed-lock 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +156 -56
  2. package/examples/index.ts +49 -21
  3. package/package.json +2 -2
  4. package/redis-semaphore/.codeclimate.yml +5 -0
  5. package/redis-semaphore/.fossa.yml +14 -0
  6. package/redis-semaphore/.github/dependabot.yml +6 -0
  7. package/redis-semaphore/.github/workflows/branches.yml +39 -0
  8. package/redis-semaphore/.github/workflows/pull-requests.yml +35 -0
  9. package/redis-semaphore/.mocharc.yaml +6 -0
  10. package/redis-semaphore/.prettierrc +6 -0
  11. package/redis-semaphore/.snyk +4 -0
  12. package/redis-semaphore/.yarnrc.yml +2 -0
  13. package/redis-semaphore/CHANGELOG.md +70 -0
  14. package/redis-semaphore/Dockerfile +5 -0
  15. package/redis-semaphore/LICENSE +21 -0
  16. package/redis-semaphore/README.md +445 -0
  17. package/redis-semaphore/docker-compose.yml +31 -0
  18. package/redis-semaphore/eslint.config.mjs +73 -0
  19. package/redis-semaphore/package.json +79 -0
  20. package/redis-semaphore/setup-redis-servers.sh +2 -0
  21. package/redis-semaphore/src/Lock.ts +172 -0
  22. package/redis-semaphore/src/RedisMultiSemaphore.ts +56 -0
  23. package/redis-semaphore/src/RedisMutex.ts +45 -0
  24. package/redis-semaphore/src/RedisSemaphore.ts +49 -0
  25. package/redis-semaphore/src/RedlockMultiSemaphore.ts +56 -0
  26. package/redis-semaphore/src/RedlockMutex.ts +52 -0
  27. package/redis-semaphore/src/RedlockSemaphore.ts +49 -0
  28. package/redis-semaphore/src/errors/LostLockError.ts +1 -0
  29. package/redis-semaphore/src/errors/TimeoutError.ts +1 -0
  30. package/redis-semaphore/src/index.ts +23 -0
  31. package/redis-semaphore/src/misc.ts +12 -0
  32. package/redis-semaphore/src/multiSemaphore/acquire/index.ts +53 -0
  33. package/redis-semaphore/src/multiSemaphore/acquire/lua.ts +31 -0
  34. package/redis-semaphore/src/multiSemaphore/refresh/index.ts +32 -0
  35. package/redis-semaphore/src/multiSemaphore/refresh/lua.ts +31 -0
  36. package/redis-semaphore/src/multiSemaphore/release/index.ts +22 -0
  37. package/redis-semaphore/src/multiSemaphore/release/lua.ts +17 -0
  38. package/redis-semaphore/src/mutex/acquire.ts +42 -0
  39. package/redis-semaphore/src/mutex/refresh.ts +37 -0
  40. package/redis-semaphore/src/mutex/release.ts +30 -0
  41. package/redis-semaphore/src/redlockMultiSemaphore/acquire.ts +56 -0
  42. package/redis-semaphore/src/redlockMultiSemaphore/refresh.ts +68 -0
  43. package/redis-semaphore/src/redlockMultiSemaphore/release.ts +19 -0
  44. package/redis-semaphore/src/redlockMutex/acquire.ts +54 -0
  45. package/redis-semaphore/src/redlockMutex/refresh.ts +53 -0
  46. package/redis-semaphore/src/redlockMutex/release.ts +19 -0
  47. package/redis-semaphore/src/redlockSemaphore/acquire.ts +55 -0
  48. package/redis-semaphore/src/redlockSemaphore/refresh.ts +60 -0
  49. package/redis-semaphore/src/redlockSemaphore/release.ts +18 -0
  50. package/redis-semaphore/src/semaphore/acquire/index.ts +52 -0
  51. package/redis-semaphore/src/semaphore/acquire/lua.ts +25 -0
  52. package/redis-semaphore/src/semaphore/refresh/index.ts +31 -0
  53. package/redis-semaphore/src/semaphore/refresh/lua.ts +25 -0
  54. package/redis-semaphore/src/semaphore/release.ts +14 -0
  55. package/redis-semaphore/src/types.ts +63 -0
  56. package/redis-semaphore/src/utils/createEval.ts +45 -0
  57. package/redis-semaphore/src/utils/index.ts +13 -0
  58. package/redis-semaphore/src/utils/redlock.ts +7 -0
  59. package/redis-semaphore/test/init.test.ts +9 -0
  60. package/redis-semaphore/test/redisClient.ts +82 -0
  61. package/redis-semaphore/test/setup.ts +6 -0
  62. package/redis-semaphore/test/shell.test.ts +15 -0
  63. package/redis-semaphore/test/shell.ts +48 -0
  64. package/redis-semaphore/test/src/Lock.test.ts +37 -0
  65. package/redis-semaphore/test/src/RedisMultiSemaphore.test.ts +425 -0
  66. package/redis-semaphore/test/src/RedisMutex.test.ts +334 -0
  67. package/redis-semaphore/test/src/RedisSemaphore.test.ts +367 -0
  68. package/redis-semaphore/test/src/RedlockMultiSemaphore.test.ts +671 -0
  69. package/redis-semaphore/test/src/RedlockMutex.test.ts +328 -0
  70. package/redis-semaphore/test/src/RedlockSemaphore.test.ts +579 -0
  71. package/redis-semaphore/test/src/index.test.ts +22 -0
  72. package/redis-semaphore/test/src/multiSemaphore/acquire/index.test.ts +51 -0
  73. package/redis-semaphore/test/src/multiSemaphore/acquire/internal.test.ts +67 -0
  74. package/redis-semaphore/test/src/multiSemaphore/refresh/index.test.ts +52 -0
  75. package/redis-semaphore/test/src/multiSemaphore/release/index.test.ts +18 -0
  76. package/redis-semaphore/test/src/mutex/acquire.test.ts +78 -0
  77. package/redis-semaphore/test/src/mutex/refresh.test.ts +22 -0
  78. package/redis-semaphore/test/src/mutex/release.test.ts +17 -0
  79. package/redis-semaphore/test/src/redlockMutex/acquire.test.ts +90 -0
  80. package/redis-semaphore/test/src/redlockMutex/refresh.test.ts +27 -0
  81. package/redis-semaphore/test/src/redlockMutex/release.test.ts +17 -0
  82. package/redis-semaphore/test/src/semaphore/acquire/index.test.ts +49 -0
  83. package/redis-semaphore/test/src/semaphore/acquire/internal.test.ts +65 -0
  84. package/redis-semaphore/test/src/semaphore/refresh/index.test.ts +44 -0
  85. package/redis-semaphore/test/src/semaphore/release.test.ts +18 -0
  86. package/redis-semaphore/test/src/utils/eval.test.ts +22 -0
  87. package/redis-semaphore/test/src/utils/index.test.ts +19 -0
  88. package/redis-semaphore/test/src/utils/redlock.test.ts +31 -0
  89. package/redis-semaphore/test/unhandledRejection.ts +28 -0
  90. package/redis-semaphore/tsconfig.build-commonjs.json +9 -0
  91. package/redis-semaphore/tsconfig.build-es.json +9 -0
  92. package/redis-semaphore/tsconfig.json +11 -0
  93. package/redis-semaphore/yarn.lock +5338 -0
  94. package/src/Backing.ts +87 -0
  95. package/src/DistributedSemaphore.ts +448 -0
  96. package/src/Errors.ts +3 -15
  97. package/src/RedisBacking.ts +165 -59
  98. package/src/index.ts +28 -12
  99. package/src/DistributedMutex.ts +0 -356
package/src/Backing.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { Context, Data, Duration, Effect } from "effect";
2
+
3
+ // =============================================================================
4
+ // Errors
5
+ // =============================================================================
6
+
7
+ /**
8
+ * Error from the backing store (Redis, etc.)
9
+ */
10
+ export class SemaphoreBackingError extends Data.TaggedError(
11
+ "SemaphoreBackingError"
12
+ )<{
13
+ readonly operation: string;
14
+ readonly cause: unknown;
15
+ }> {
16
+ get message() {
17
+ return `Backing store error during "${this.operation}": ${this.cause}`;
18
+ }
19
+ }
20
+
21
+ // =============================================================================
22
+ // Backing Service
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Low-level backing store interface for distributed semaphore operations.
27
+ * Implementations handle the actual storage (Redis, etc.)
28
+ *
29
+ * The semaphore uses a sorted set model where:
30
+ * - Each permit holder is stored with their acquisition timestamp as the score
31
+ * - Expired entries are cleaned up automatically
32
+ * - Multiple permits can be acquired atomically
33
+ */
34
+ export interface DistributedSemaphoreBacking {
35
+ /**
36
+ * Try to acquire `permits` from a semaphore with the given `limit`.
37
+ * Returns true if acquired, false if not enough permits available.
38
+ *
39
+ * The implementation should:
40
+ * 1. Clean up expired entries (based on TTL)
41
+ * 2. Check if there's room: currentCount + permits <= limit
42
+ * 3. If so, add the permits with current timestamp
43
+ */
44
+ readonly tryAcquire: (
45
+ key: string,
46
+ holderId: string,
47
+ ttl: Duration.Duration,
48
+ limit: number,
49
+ permits: number
50
+ ) => Effect.Effect<boolean, SemaphoreBackingError>;
51
+
52
+ /**
53
+ * Release `permits` held by this holder.
54
+ * Returns the number of permits actually released.
55
+ */
56
+ readonly release: (
57
+ key: string,
58
+ holderId: string,
59
+ permits: number
60
+ ) => Effect.Effect<number, SemaphoreBackingError>;
61
+
62
+ /**
63
+ * Refresh the TTL on permits we hold.
64
+ * Returns true if refreshed, false if permits were lost.
65
+ */
66
+ readonly refresh: (
67
+ key: string,
68
+ holderId: string,
69
+ ttl: Duration.Duration,
70
+ limit: number,
71
+ permits: number
72
+ ) => Effect.Effect<boolean, SemaphoreBackingError>;
73
+
74
+ /**
75
+ * Get the number of permits currently held (in use).
76
+ * Available permits = limit - getCount().
77
+ */
78
+ readonly getCount: (
79
+ key: string,
80
+ ttl: Duration.Duration
81
+ ) => Effect.Effect<number, SemaphoreBackingError>;
82
+ }
83
+
84
+ export const DistributedSemaphoreBacking =
85
+ Context.GenericTag<DistributedSemaphoreBacking>(
86
+ "@effect-distributed-lock/DistributedSemaphoreBacking"
87
+ );
@@ -0,0 +1,448 @@
1
+ import {
2
+ Deferred,
3
+ Duration,
4
+ Effect,
5
+ Fiber,
6
+ Option,
7
+ Schedule,
8
+ Scope,
9
+ } from "effect";
10
+ import {
11
+ DistributedSemaphoreBacking,
12
+ SemaphoreBackingError,
13
+ } from "./Backing.js";
14
+ import { LockLostError, NotYetAcquiredError } from "./Errors.js";
15
+
16
+ // =============================================================================
17
+ // Semaphore Configuration
18
+ // =============================================================================
19
+
20
+ export interface DistributedSemaphoreConfig {
21
+ /**
22
+ * Maximum number of permits available.
23
+ * This is the total capacity of the semaphore.
24
+ * @default 1 (mutex behavior)
25
+ */
26
+ readonly limit?: number;
27
+
28
+ /**
29
+ * TTL for held permits. If the holder crashes, permits auto-release after this.
30
+ * @default 30 seconds
31
+ */
32
+ readonly ttl?: Duration.DurationInput;
33
+
34
+ /**
35
+ * How often to refresh the TTL while holding permits.
36
+ * Should be less than TTL to avoid losing the permits.
37
+ * @default 1/3 of TTL
38
+ */
39
+ readonly refreshInterval?: Duration.DurationInput;
40
+
41
+ /**
42
+ * How often to poll when waiting to acquire permits.
43
+ * @default Schedule.spaced(Duration.millis(100))
44
+ */
45
+ readonly acquireRetryPolicy?: Schedule.Schedule<void>;
46
+
47
+ /**
48
+ * Retry policy when a backing failure occurs.
49
+ * This could happen when:
50
+ * - Trying to acquire permits
51
+ * - Refreshing the TTL
52
+ * - Releasing permits
53
+ * @default Schedule.recurs(3)
54
+ */
55
+ readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
56
+ }
57
+
58
+ const DEFAULT_LIMIT = 1;
59
+ const DEFAULT_TTL = Duration.seconds(30);
60
+ const DEFAULT_ACQUIRE_RETRY_POLICY = Schedule.spaced(Duration.millis(100)).pipe(
61
+ Schedule.asVoid
62
+ );
63
+ const DEFAULT_FAILURE_RETRY_POLICY = Schedule.recurs(3).pipe(Schedule.asVoid);
64
+
65
+ // =============================================================================
66
+ // Acquire Options
67
+ // =============================================================================
68
+
69
+ /**
70
+ * Options for acquire operations (take, tryTake, withPermits, etc.)
71
+ */
72
+ export interface AcquireOptions {
73
+ /**
74
+ * Unique identifier for this permit holder.
75
+ *
76
+ * By default, a random UUID is generated per-acquire. Override this if you need:
77
+ * - Predictable identifiers for debugging/observability
78
+ * - Cross-process lock handoff (acquire in one process, release in another)
79
+ *
80
+ * ⚠️ **Warning**: Must be unique across concurrent holders, otherwise locks with the same
81
+ * identifier may be treated as the same holder.
82
+ *
83
+ * @default crypto.randomUUID()
84
+ */
85
+ readonly identifier?: string;
86
+
87
+ /**
88
+ * If true, assumes the permits were already acquired externally with the given identifier.
89
+ * Instead of acquiring, uses refresh to verify ownership.
90
+ *
91
+ * **Requires `identifier` to be provided.**
92
+ *
93
+ * This is useful for cross-process lock handoff:
94
+ * 1. Process A acquires permits with a known identifier
95
+ * 2. Process A passes the identifier to Process B (via message queue, etc.)
96
+ * 3. Process B calls take/withPermits with `{ identifier, acquiredExternally: true }`
97
+ * 4. Process B now owns the permits (refreshing and releasing)
98
+ *
99
+ * ⚠️ **Unsafe**: If the identifier is wrong or the lock expired, this will fail immediately.
100
+ *
101
+ * @default false
102
+ */
103
+ readonly acquiredExternally?: boolean;
104
+ }
105
+
106
+ // =============================================================================
107
+ // Distributed Semaphore Interface
108
+ // =============================================================================
109
+
110
+ /**
111
+ * A distributed semaphore that can be used across multiple processes/services.
112
+ *
113
+ * Similar to Effect's built-in Semaphore, but distributed across processes
114
+ * using a backing store like Redis.
115
+ *
116
+ * A semaphore manages a pool of permits. Tasks can acquire one or more permits,
117
+ * and must release them when done. If not enough permits are available, tasks
118
+ * wait until permits are released.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // Create a semaphore that allows 5 concurrent operations
123
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
124
+ *
125
+ * // Acquire 2 permits, run effect, then release
126
+ * yield* sem.withPermits(2)(myEffect);
127
+ *
128
+ * // Create a mutex (limit=1) for exclusive access
129
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
130
+ * yield* mutex.withPermits(1)(criticalSection);
131
+ * ```
132
+ */
133
+ export interface DistributedSemaphore {
134
+ /**
135
+ * The key identifying this semaphore in the backing store.
136
+ */
137
+ readonly key: string;
138
+
139
+ /**
140
+ * The maximum number of permits available.
141
+ */
142
+ readonly limit: number;
143
+
144
+ /**
145
+ * Run an effect with the specified number of permits.
146
+ * Acquires the permits, runs the effect, then releases.
147
+ * If not enough permits are available, waits until they are.
148
+ *
149
+ * The permit TTL is refreshed automatically while the effect runs.
150
+ */
151
+ readonly withPermits: (
152
+ permits: number,
153
+ options?: AcquireOptions
154
+ ) => <A, E, R>(
155
+ effect: Effect.Effect<A, E, R>
156
+ ) => Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R>;
157
+
158
+ /**
159
+ * Run an effect only if the specified permits are immediately available.
160
+ * Returns Some(result) if permits were acquired and effect ran,
161
+ * None if permits were not available.
162
+ */
163
+ readonly withPermitsIfAvailable: (
164
+ permits: number,
165
+ options?: AcquireOptions
166
+ ) => <A, E, R>(
167
+ effect: Effect.Effect<A, E, R>
168
+ ) => Effect.Effect<
169
+ Option.Option<A>,
170
+ E | LockLostError | SemaphoreBackingError,
171
+ R
172
+ >;
173
+
174
+ /**
175
+ * Acquire the specified number of permits, waiting if necessary.
176
+ * The permits are held until the scope is closed.
177
+ * The permit TTL is refreshed automatically while held.
178
+ *
179
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
180
+ * When the scope closes, the fiber is interrupted and permits are released.
181
+ */
182
+ readonly take: (
183
+ permits: number,
184
+ options?: AcquireOptions
185
+ ) => Effect.Effect<
186
+ Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
187
+ LockLostError | SemaphoreBackingError,
188
+ Scope.Scope
189
+ >;
190
+
191
+ /**
192
+ * Try to acquire permits immediately without waiting.
193
+ * Returns Some(fiber) if acquired (permits held until scope closes),
194
+ * None if permits were not available.
195
+ *
196
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
197
+ * When the scope closes, the fiber is interrupted and permits are released.
198
+ */
199
+ readonly tryTake: (
200
+ permits: number,
201
+ options?: AcquireOptions
202
+ ) => Effect.Effect<
203
+ Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
204
+ LockLostError | SemaphoreBackingError,
205
+ Scope.Scope
206
+ >;
207
+
208
+ /**
209
+ * Get the current number of active permits (for debugging/introspection).
210
+ * Note: This is eventually consistent due to TTL-based expiration.
211
+ */
212
+ readonly currentCount: Effect.Effect<number, SemaphoreBackingError>;
213
+ }
214
+
215
+ // =============================================================================
216
+ // Factory
217
+ // =============================================================================
218
+
219
+ type FullyResolvedConfig = {
220
+ limit: number;
221
+ ttl: Duration.Duration;
222
+ refreshInterval: Duration.Duration;
223
+ acquireRetryPolicy: Schedule.Schedule<void>;
224
+ backingFailureRetryPolicy: Schedule.Schedule<void>;
225
+ };
226
+
227
+ function fullyResolveConfig(
228
+ config: DistributedSemaphoreConfig
229
+ ): FullyResolvedConfig {
230
+ const limit = config.limit ?? DEFAULT_LIMIT;
231
+ const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
232
+ const refreshInterval = config.refreshInterval
233
+ ? Duration.decode(config.refreshInterval)
234
+ : Duration.millis(Duration.toMillis(ttl) / 3);
235
+ const acquireRetryPolicy = config.acquireRetryPolicy
236
+ ? config.acquireRetryPolicy
237
+ : DEFAULT_ACQUIRE_RETRY_POLICY;
238
+ const backingFailureRetryPolicy = config.backingFailureRetryPolicy
239
+ ? config.backingFailureRetryPolicy
240
+ : DEFAULT_FAILURE_RETRY_POLICY;
241
+
242
+ return {
243
+ limit,
244
+ ttl,
245
+ refreshInterval,
246
+ acquireRetryPolicy,
247
+ backingFailureRetryPolicy,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Create a distributed semaphore for the given key.
253
+ *
254
+ * @param key - Unique identifier for this semaphore in the backing store
255
+ * @param config - Configuration options
256
+ *
257
+ * @example
258
+ * ```ts
259
+ * // Create a semaphore that allows 5 concurrent operations
260
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
261
+ *
262
+ * // Use withPermits to run an effect with acquired permits
263
+ * yield* sem.withPermits(2)(Effect.log("I have 2 permits"));
264
+ *
265
+ * // Use withPermitsIfAvailable to try without waiting
266
+ * const result = yield* sem.withPermitsIfAvailable(1)(Effect.succeed(42));
267
+ * // result: Option<number>
268
+ * ```
269
+ */
270
+ export const make = (
271
+ key: string,
272
+ config: DistributedSemaphoreConfig = {}
273
+ ): Effect.Effect<DistributedSemaphore, never, DistributedSemaphoreBacking> =>
274
+ Effect.gen(function* () {
275
+ const backing = yield* DistributedSemaphoreBacking;
276
+
277
+ // Resolve config with defaults
278
+ const {
279
+ limit,
280
+ ttl,
281
+ refreshInterval,
282
+ acquireRetryPolicy,
283
+ backingFailureRetryPolicy,
284
+ } = fullyResolveConfig(config);
285
+
286
+ const withBackingErrorRetry = <A, E extends { _tag: string }, R>(
287
+ effect: Effect.Effect<A, E | SemaphoreBackingError, R>
288
+ ) =>
289
+ effect.pipe(
290
+ Effect.retry({
291
+ while: (e) => e._tag === "SemaphoreBackingError",
292
+ schedule: backingFailureRetryPolicy,
293
+ })
294
+ );
295
+
296
+ // Keep the permits alive by refreshing TTL periodically.
297
+ // This effect runs forever until interrupted (when scope closes).
298
+ const keepAlive = (
299
+ identifier: string,
300
+ permits: number
301
+ ): Effect.Effect<never, SemaphoreBackingError | LockLostError, never> =>
302
+ Effect.repeat(
303
+ Effect.gen(function* () {
304
+ const refreshed = yield* backing
305
+ .refresh(key, identifier, ttl, limit, permits)
306
+ .pipe(withBackingErrorRetry);
307
+
308
+ if (!refreshed) {
309
+ return yield* new LockLostError({ key });
310
+ }
311
+ }),
312
+ Schedule.spaced(refreshInterval)
313
+ ).pipe(
314
+ Effect.andThen(
315
+ Effect.dieMessage(
316
+ "Invariant violated: `keepAlive` should never return a value"
317
+ )
318
+ )
319
+ );
320
+
321
+ // Try to acquire permits immediately, returns Option
322
+ const tryTake = (
323
+ permits: number,
324
+ options?: AcquireOptions
325
+ ): Effect.Effect<
326
+ Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
327
+ SemaphoreBackingError,
328
+ Scope.Scope
329
+ > =>
330
+ Effect.gen(function* () {
331
+ // Generate identifier per-acquire if not provided
332
+ const identifier = options?.identifier ?? crypto.randomUUID();
333
+ const acquiredExternally = options?.acquiredExternally ?? false;
334
+
335
+ // If acquiredExternally, use refresh to verify ownership instead of acquire
336
+ const acquired = acquiredExternally
337
+ ? yield* backing
338
+ .refresh(key, identifier, ttl, limit, permits)
339
+ .pipe(withBackingErrorRetry)
340
+ : yield* backing
341
+ .tryAcquire(key, identifier, ttl, limit, permits)
342
+ .pipe(withBackingErrorRetry);
343
+
344
+ if (!acquired) {
345
+ return Option.none();
346
+ }
347
+
348
+ // Start keepalive fiber, tied to this scope
349
+ const keepAliveFiber = yield* Effect.forkScoped(
350
+ keepAlive(identifier, permits)
351
+ );
352
+
353
+ // Add finalizer to release permits when scope closes
354
+ yield* Effect.addFinalizer(() =>
355
+ backing
356
+ .release(key, identifier, permits)
357
+ .pipe(withBackingErrorRetry, Effect.ignore)
358
+ );
359
+
360
+ return Option.some(keepAliveFiber);
361
+ });
362
+
363
+ // Acquire permits with retry, returns fiber when acquired
364
+ const take = (
365
+ permits: number,
366
+ options?: AcquireOptions
367
+ ): Effect.Effect<
368
+ Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
369
+ SemaphoreBackingError,
370
+ Scope.Scope
371
+ > =>
372
+ Effect.gen(function* () {
373
+ // Generate identifier once for all retry attempts (outside the retry loop)
374
+ const identifier = options?.identifier ?? crypto.randomUUID();
375
+ const resolvedOptions: AcquireOptions = {
376
+ identifier,
377
+ acquiredExternally: options?.acquiredExternally,
378
+ };
379
+ const maybeAcquired = yield* tryTake(permits, resolvedOptions);
380
+ if (Option.isNone(maybeAcquired)) {
381
+ return yield* new NotYetAcquiredError();
382
+ }
383
+ return maybeAcquired.value;
384
+ }).pipe(
385
+ Effect.retry({
386
+ while: (e) => e._tag === "NotYetAcquiredError",
387
+ schedule: acquireRetryPolicy,
388
+ }),
389
+ Effect.catchTag("NotYetAcquiredError", () =>
390
+ Effect.dieMessage(
391
+ "Invariant violated: `take` should never return `NotYetAcquiredError` " +
392
+ "since it should be caught by the retry which should retry forever until permits are acquired"
393
+ )
394
+ )
395
+ );
396
+
397
+ // Convenience: acquire permits, run effect, release when done
398
+ const withPermits =
399
+ (permits: number, options?: AcquireOptions) =>
400
+ <A, E, R>(
401
+ effect: Effect.Effect<A, E, R>
402
+ ): Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R> =>
403
+ Effect.scoped(
404
+ Effect.gen(function* () {
405
+ const keepAliveFiber = yield* take(permits, options);
406
+
407
+ return yield* Effect.raceFirst(effect, Fiber.join(keepAliveFiber));
408
+ })
409
+ );
410
+
411
+ // Convenience: try to acquire permits, run effect if successful
412
+ const withPermitsIfAvailable =
413
+ (permits: number, options?: AcquireOptions) =>
414
+ <A, E, R>(
415
+ effect: Effect.Effect<A, E, R>
416
+ ): Effect.Effect<
417
+ Option.Option<A>,
418
+ E | LockLostError | SemaphoreBackingError,
419
+ R
420
+ > =>
421
+ Effect.scoped(
422
+ Effect.gen(function* () {
423
+ const maybeAcquired = yield* tryTake(permits, options);
424
+ if (Option.isNone(maybeAcquired)) {
425
+ return Option.none();
426
+ }
427
+ const keepAliveFiber = maybeAcquired.value;
428
+ return yield* Effect.raceFirst(
429
+ effect.pipe(Effect.asSome),
430
+ Fiber.join(keepAliveFiber)
431
+ );
432
+ })
433
+ );
434
+
435
+ const currentCount: Effect.Effect<number, SemaphoreBackingError> = backing
436
+ .getCount(key, ttl)
437
+ .pipe(withBackingErrorRetry);
438
+
439
+ return {
440
+ key,
441
+ limit,
442
+ withPermits,
443
+ withPermitsIfAvailable,
444
+ take,
445
+ tryTake,
446
+ currentCount,
447
+ } satisfies DistributedSemaphore;
448
+ });
package/src/Errors.ts CHANGED
@@ -1,30 +1,18 @@
1
1
  import { Data } from "effect";
2
2
 
3
3
  /**
4
- * The lock was lost (TTL expired while we thought we held it)
4
+ * The permits were lost (TTL expired while we thought we held them)
5
5
  */
6
6
  export class LockLostError extends Data.TaggedError("LockLostError")<{
7
7
  readonly key: string;
8
8
  }> {
9
9
  get message() {
10
- return `Lock "${this.key}" was lost (TTL expired or taken by another holder)`;
10
+ return `Permits for "${this.key}" were lost (TTL expired or taken by another holder)`;
11
11
  }
12
12
  }
13
13
 
14
14
  /**
15
- * Error from the backing store (Redis, etc.)
16
- */
17
- export class MutexBackingError extends Data.TaggedError("MutexBackingError")<{
18
- readonly operation: string;
19
- readonly cause: unknown;
20
- }> {
21
- get message() {
22
- return `Backing store error during "${this.operation}": ${this.cause}`;
23
- }
24
- }
25
-
26
- /**
27
- * Internal error: lock not yet acquired (used for retry logic)
15
+ * Internal error: permits not yet acquired (used for retry logic)
28
16
  * @internal
29
17
  */
30
18
  export class NotYetAcquiredError extends Data.TaggedError(