effect-distributed-lock 0.0.2 → 0.0.4

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 +97 -66
  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 +384 -0
  96. package/src/Errors.ts +3 -39
  97. package/src/RedisBacking.ts +167 -165
  98. package/src/index.ts +28 -17
  99. package/src/DistributedMutex.ts +0 -304
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,384 @@
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 100ms
44
+ */
45
+ readonly acquireRetryInterval?: Duration.DurationInput;
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
+ */
54
+ readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
55
+ }
56
+
57
+ const DEFAULT_LIMIT = 1;
58
+ const DEFAULT_TTL = Duration.seconds(30);
59
+ const DEFAULT_ACQUIRE_RETRY_INTERVAL = Duration.millis(100);
60
+ const DEFAULT_FAILURE_RETRY_POLICY = Schedule.spaced(
61
+ DEFAULT_ACQUIRE_RETRY_INTERVAL
62
+ ).pipe(Schedule.asVoid);
63
+
64
+ // =============================================================================
65
+ // Distributed Semaphore Interface
66
+ // =============================================================================
67
+
68
+ /**
69
+ * A distributed semaphore that can be used across multiple processes/services.
70
+ *
71
+ * Similar to Effect's built-in Semaphore, but distributed across processes
72
+ * using a backing store like Redis.
73
+ *
74
+ * A semaphore manages a pool of permits. Tasks can acquire one or more permits,
75
+ * and must release them when done. If not enough permits are available, tasks
76
+ * wait until permits are released.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * // Create a semaphore that allows 5 concurrent operations
81
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
82
+ *
83
+ * // Acquire 2 permits, run effect, then release
84
+ * yield* sem.withPermits(2)(myEffect);
85
+ *
86
+ * // Create a mutex (limit=1) for exclusive access
87
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
88
+ * yield* mutex.withPermits(1)(criticalSection);
89
+ * ```
90
+ */
91
+ export interface DistributedSemaphore {
92
+ /**
93
+ * The key identifying this semaphore in the backing store.
94
+ */
95
+ readonly key: string;
96
+
97
+ /**
98
+ * The maximum number of permits available.
99
+ */
100
+ readonly limit: number;
101
+
102
+ /**
103
+ * Run an effect with the specified number of permits.
104
+ * Acquires the permits, runs the effect, then releases.
105
+ * If not enough permits are available, waits until they are.
106
+ *
107
+ * The permit TTL is refreshed automatically while the effect runs.
108
+ */
109
+ readonly withPermits: (
110
+ permits: number
111
+ ) => <A, E, R>(
112
+ effect: Effect.Effect<A, E, R>
113
+ ) => Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R>;
114
+
115
+ /**
116
+ * Run an effect only if the specified permits are immediately available.
117
+ * Returns Some(result) if permits were acquired and effect ran,
118
+ * None if permits were not available.
119
+ */
120
+ readonly withPermitsIfAvailable: (
121
+ permits: number
122
+ ) => <A, E, R>(
123
+ effect: Effect.Effect<A, E, R>
124
+ ) => Effect.Effect<
125
+ Option.Option<A>,
126
+ E | LockLostError | SemaphoreBackingError,
127
+ R
128
+ >;
129
+
130
+ /**
131
+ * Acquire the specified number of permits, waiting if necessary.
132
+ * The permits are held until the scope is closed.
133
+ * The permit TTL is refreshed automatically while held.
134
+ *
135
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
136
+ * When the scope closes, the fiber is interrupted and permits are released.
137
+ */
138
+ readonly take: (
139
+ permits: number
140
+ ) => Effect.Effect<
141
+ Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
142
+ LockLostError | SemaphoreBackingError,
143
+ Scope.Scope
144
+ >;
145
+
146
+ /**
147
+ * Try to acquire permits immediately without waiting.
148
+ * Returns Some(fiber) if acquired (permits held until scope closes),
149
+ * None if permits were not available.
150
+ *
151
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
152
+ * When the scope closes, the fiber is interrupted and permits are released.
153
+ */
154
+ readonly tryTake: (
155
+ permits: number
156
+ ) => Effect.Effect<
157
+ Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
158
+ LockLostError | SemaphoreBackingError,
159
+ Scope.Scope
160
+ >;
161
+
162
+ /**
163
+ * Get the current number of active permits (for debugging/introspection).
164
+ * Note: This is eventually consistent due to TTL-based expiration.
165
+ */
166
+ readonly currentCount: Effect.Effect<number, SemaphoreBackingError>;
167
+ }
168
+
169
+ // =============================================================================
170
+ // Factory
171
+ // =============================================================================
172
+
173
+ type FullyResolvedConfig = {
174
+ limit: number;
175
+ ttl: Duration.Duration;
176
+ refreshInterval: Duration.Duration;
177
+ acquireRetryInterval: Duration.Duration;
178
+ backingFailureRetryPolicy: Schedule.Schedule<void>;
179
+ };
180
+
181
+ function fullyResolveConfig(
182
+ config: DistributedSemaphoreConfig
183
+ ): FullyResolvedConfig {
184
+ const limit = config.limit ?? DEFAULT_LIMIT;
185
+ const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
186
+ const refreshInterval = config.refreshInterval
187
+ ? Duration.decode(config.refreshInterval)
188
+ : Duration.millis(Duration.toMillis(ttl) / 3);
189
+ const acquireRetryInterval = config.acquireRetryInterval
190
+ ? Duration.decode(config.acquireRetryInterval)
191
+ : DEFAULT_ACQUIRE_RETRY_INTERVAL;
192
+ const backingFailureRetryPolicy = config.backingFailureRetryPolicy
193
+ ? config.backingFailureRetryPolicy
194
+ : DEFAULT_FAILURE_RETRY_POLICY;
195
+
196
+ return {
197
+ limit,
198
+ ttl,
199
+ refreshInterval,
200
+ acquireRetryInterval,
201
+ backingFailureRetryPolicy,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Create a distributed semaphore for the given key.
207
+ *
208
+ * @param key - Unique identifier for this semaphore in the backing store
209
+ * @param config - Configuration options
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * // Create a semaphore that allows 5 concurrent operations
214
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
215
+ *
216
+ * // Use withPermits to run an effect with acquired permits
217
+ * yield* sem.withPermits(2)(Effect.log("I have 2 permits"));
218
+ *
219
+ * // Use withPermitsIfAvailable to try without waiting
220
+ * const result = yield* sem.withPermitsIfAvailable(1)(Effect.succeed(42));
221
+ * // result: Option<number>
222
+ * ```
223
+ */
224
+ export const make = (
225
+ key: string,
226
+ config: DistributedSemaphoreConfig = {}
227
+ ): Effect.Effect<DistributedSemaphore, never, DistributedSemaphoreBacking> =>
228
+ Effect.gen(function* () {
229
+ const backing = yield* DistributedSemaphoreBacking;
230
+
231
+ // Generate unique holder ID for this instance
232
+ const holderId = crypto.randomUUID();
233
+
234
+ // Resolve config with defaults
235
+ const {
236
+ limit,
237
+ ttl,
238
+ refreshInterval,
239
+ acquireRetryInterval,
240
+ backingFailureRetryPolicy,
241
+ } = fullyResolveConfig(config);
242
+
243
+ const withBackingErrorRetry = <A, E extends { _tag: string }, R>(
244
+ effect: Effect.Effect<A, E | SemaphoreBackingError, R>
245
+ ) =>
246
+ effect.pipe(
247
+ Effect.retry({
248
+ while: (e) => e._tag === "SemaphoreBackingError",
249
+ schedule: backingFailureRetryPolicy,
250
+ })
251
+ );
252
+
253
+ // Keep the permits alive by refreshing TTL periodically.
254
+ // This effect runs forever until interrupted (when scope closes).
255
+ const keepAlive = (
256
+ permits: number
257
+ ): Effect.Effect<never, SemaphoreBackingError | LockLostError, never> =>
258
+ Effect.repeat(
259
+ Effect.gen(function* () {
260
+ const refreshed = yield* backing
261
+ .refresh(key, holderId, ttl, limit, permits)
262
+ .pipe(withBackingErrorRetry);
263
+
264
+ if (!refreshed) {
265
+ return yield* new LockLostError({ key });
266
+ }
267
+ }),
268
+ Schedule.spaced(refreshInterval)
269
+ ).pipe(
270
+ Effect.andThen(
271
+ Effect.dieMessage(
272
+ "Invariant violated: `keepAlive` should never return a value"
273
+ )
274
+ )
275
+ );
276
+
277
+ // Try to acquire permits immediately, returns Option
278
+ const tryTake = (
279
+ permits: number
280
+ ): Effect.Effect<
281
+ Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
282
+ SemaphoreBackingError,
283
+ Scope.Scope
284
+ > =>
285
+ Effect.gen(function* () {
286
+ const acquired = yield* backing
287
+ .tryAcquire(key, holderId, ttl, limit, permits)
288
+ .pipe(withBackingErrorRetry);
289
+ if (!acquired) {
290
+ return Option.none();
291
+ }
292
+
293
+ // Start keepalive fiber, tied to this scope
294
+ const keepAliveFiber = yield* Effect.forkScoped(keepAlive(permits));
295
+
296
+ // Add finalizer to release permits when scope closes
297
+ yield* Effect.addFinalizer(() =>
298
+ backing
299
+ .release(key, holderId, permits)
300
+ .pipe(withBackingErrorRetry, Effect.ignore)
301
+ );
302
+
303
+ return Option.some(keepAliveFiber);
304
+ });
305
+
306
+ // Acquire permits with retry, returns fiber when acquired
307
+ const take = (
308
+ permits: number
309
+ ): Effect.Effect<
310
+ Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
311
+ SemaphoreBackingError,
312
+ Scope.Scope
313
+ > =>
314
+ Effect.gen(function* () {
315
+ const maybeAcquired = yield* tryTake(permits);
316
+ if (Option.isNone(maybeAcquired)) {
317
+ return yield* new NotYetAcquiredError();
318
+ }
319
+ return maybeAcquired.value;
320
+ }).pipe(
321
+ Effect.retry({
322
+ while: (e) => e._tag === "NotYetAcquiredError",
323
+ schedule: Schedule.spaced(acquireRetryInterval),
324
+ }),
325
+ Effect.catchTag("NotYetAcquiredError", () =>
326
+ Effect.dieMessage(
327
+ "Invariant violated: `take` should never return `NotYetAcquiredError` " +
328
+ "since it should be caught by the retry which should retry forever until permits are acquired"
329
+ )
330
+ )
331
+ );
332
+
333
+ // Convenience: acquire permits, run effect, release when done
334
+ const withPermits =
335
+ (permits: number) =>
336
+ <A, E, R>(
337
+ effect: Effect.Effect<A, E, R>
338
+ ): Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R> =>
339
+ Effect.scoped(
340
+ Effect.gen(function* () {
341
+ const keepAliveFiber = yield* take(permits);
342
+
343
+ return yield* Effect.raceFirst(effect, Fiber.join(keepAliveFiber));
344
+ })
345
+ );
346
+
347
+ // Convenience: try to acquire permits, run effect if successful
348
+ const withPermitsIfAvailable =
349
+ (permits: number) =>
350
+ <A, E, R>(
351
+ effect: Effect.Effect<A, E, R>
352
+ ): Effect.Effect<
353
+ Option.Option<A>,
354
+ E | LockLostError | SemaphoreBackingError,
355
+ R
356
+ > =>
357
+ Effect.scoped(
358
+ Effect.gen(function* () {
359
+ const maybeAcquired = yield* tryTake(permits);
360
+ if (Option.isNone(maybeAcquired)) {
361
+ return Option.none();
362
+ }
363
+ const keepAliveFiber = maybeAcquired.value;
364
+ return yield* Effect.raceFirst(
365
+ effect.pipe(Effect.asSome),
366
+ Fiber.join(keepAliveFiber)
367
+ );
368
+ })
369
+ );
370
+
371
+ const currentCount: Effect.Effect<number, SemaphoreBackingError> = backing
372
+ .getCount(key, ttl)
373
+ .pipe(withBackingErrorRetry);
374
+
375
+ return {
376
+ key,
377
+ limit,
378
+ withPermits,
379
+ withPermitsIfAvailable,
380
+ take,
381
+ tryTake,
382
+ currentCount,
383
+ } satisfies DistributedSemaphore;
384
+ });
package/src/Errors.ts CHANGED
@@ -1,54 +1,18 @@
1
1
  import { Data } from "effect";
2
2
 
3
3
  /**
4
- * Base error for all distributed mutex errors
5
- */
6
- export class DistributedMutexError extends Data.TaggedError(
7
- "DistributedMutexError"
8
- )<{
9
- readonly message: string;
10
- readonly cause?: unknown;
11
- }> {}
12
-
13
- /**
14
- * Failed to acquire the lock within the timeout period
15
- */
16
- export class AcquireTimeoutError extends Data.TaggedError(
17
- "AcquireTimeoutError"
18
- )<{
19
- readonly key: string;
20
- readonly timeoutMs: number;
21
- }> {
22
- get message() {
23
- return `Failed to acquire lock "${this.key}" within ${this.timeoutMs}ms`;
24
- }
25
- }
26
-
27
- /**
28
- * 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)
29
5
  */
30
6
  export class LockLostError extends Data.TaggedError("LockLostError")<{
31
7
  readonly key: string;
32
8
  }> {
33
9
  get message() {
34
- return `Lock "${this.key}" was lost (TTL expired or taken by another holder)`;
35
- }
36
- }
37
-
38
- /**
39
- * Error from the backing store (Redis, etc.)
40
- */
41
- export class BackingError extends Data.TaggedError("BackingError")<{
42
- readonly operation: string;
43
- readonly cause: unknown;
44
- }> {
45
- get message() {
46
- return `Backing store error during "${this.operation}": ${this.cause}`;
10
+ return `Permits for "${this.key}" were lost (TTL expired or taken by another holder)`;
47
11
  }
48
12
  }
49
13
 
50
14
  /**
51
- * Internal error: lock not yet acquired (used for retry logic)
15
+ * Internal error: permits not yet acquired (used for retry logic)
52
16
  * @internal
53
17
  */
54
18
  export class NotYetAcquiredError extends Data.TaggedError(