effect-distributed-lock 0.0.3 → 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 +90 -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 +384 -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
@@ -1,356 +0,0 @@
1
- import {
2
- Context,
3
- Duration,
4
- Effect,
5
- Fiber,
6
- Option,
7
- Schedule,
8
- Scope,
9
- } from "effect";
10
- import {
11
- MutexBackingError,
12
- LockLostError,
13
- NotYetAcquiredError,
14
- } from "./Errors.js";
15
-
16
- // =============================================================================
17
- // Backing Service
18
- // =============================================================================
19
-
20
- /**
21
- * Low-level backing store interface for distributed mutex operations.
22
- * Implementations handle the actual storage (Redis, etcd, DynamoDB, etc.)
23
- */
24
- export interface DistributedMutexBacking {
25
- /**
26
- * Try to acquire the lock. Returns true if acquired, false if already held.
27
- * Must set TTL on the lock.
28
- */
29
- readonly tryAcquire: (
30
- key: string,
31
- holderId: string,
32
- ttl: Duration.Duration
33
- ) => Effect.Effect<boolean, MutexBackingError>;
34
-
35
- /**
36
- * Release the lock. Only succeeds if we are the current holder.
37
- * Returns true if released, false if we weren't the holder.
38
- */
39
- readonly release: (
40
- key: string,
41
- holderId: string
42
- ) => Effect.Effect<boolean, MutexBackingError>;
43
-
44
- /**
45
- * Refresh the TTL on a lock we hold.
46
- * Returns true if refreshed, false if lock was lost.
47
- */
48
- readonly refresh: (
49
- key: string,
50
- holderId: string,
51
- ttl: Duration.Duration
52
- ) => Effect.Effect<boolean, MutexBackingError>;
53
-
54
- /**
55
- * Check if the lock is currently held (by anyone).
56
- */
57
- readonly isLocked: (key: string) => Effect.Effect<boolean, MutexBackingError>;
58
-
59
- /**
60
- * Get the current holder ID, if any.
61
- */
62
- readonly getHolder: (
63
- key: string
64
- ) => Effect.Effect<Option.Option<string>, MutexBackingError>;
65
- }
66
-
67
- export const DistributedMutexBacking =
68
- Context.GenericTag<DistributedMutexBacking>(
69
- "@effect-distributed-lock/DistributedMutexBacking"
70
- );
71
-
72
- // =============================================================================
73
- // Mutex Configuration
74
- // =============================================================================
75
-
76
- export interface DistributedMutexConfig {
77
- /**
78
- * TTL for the lock. If the holder crashes, the lock auto-releases after this.
79
- * @default 30 seconds
80
- */
81
- readonly ttl?: Duration.DurationInput;
82
-
83
- /**
84
- * How often to refresh the TTL while holding the lock.
85
- * Should be less than TTL to avoid losing the lock.
86
- * @default 1/3 of TTL
87
- */
88
- readonly refreshInterval?: Duration.DurationInput;
89
-
90
- /**
91
- * How often to poll when waiting to acquire the lock.
92
- * @default 100ms
93
- */
94
- readonly acquireRetryInterval?: Duration.DurationInput;
95
-
96
- /**
97
- * How often to retry when a failure occurs.
98
- * This could happen when:
99
- * - Trying to acquire the lock
100
- * - Refreshing the TTL
101
- * - Releasing the lock
102
- */
103
- readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
104
- }
105
-
106
- const DEFAULT_TTL = Duration.seconds(30);
107
- const DEFAULT_ACQUIRE_RETRY_INTERVAL = Duration.millis(100);
108
- const DEFAULT_FAILURE_RETRY_POLICY = Schedule.spaced(
109
- DEFAULT_ACQUIRE_RETRY_INTERVAL
110
- ).pipe(Schedule.asVoid);
111
-
112
- // =============================================================================
113
- // Distributed Mutex Interface
114
- // =============================================================================
115
-
116
- /**
117
- * A distributed mutex that can be used across multiple processes/services.
118
- */
119
- export interface DistributedMutex {
120
- /**
121
- * The key identifying this mutex in the backing store.
122
- */
123
- readonly key: string;
124
-
125
- /**
126
- * Acquire the lock, run the effect, then release.
127
- * If acquisition fails within timeout, returns AcquireTimeoutError.
128
- * The lock TTL is refreshed automatically while the effect runs.
129
- */
130
- readonly withLock: <A, E, R>(
131
- effect: Effect.Effect<A, E, R>
132
- ) => Effect.Effect<A, E | LockLostError | MutexBackingError, R>;
133
-
134
- /**
135
- * Try to acquire the lock immediately without waiting.
136
- * Returns Some(result) if lock was acquired and effect ran,
137
- * None if lock was not available.
138
- */
139
- readonly withLockIfAvailable: <A, E, R>(
140
- effect: Effect.Effect<A, E, R>
141
- ) => Effect.Effect<
142
- Option.Option<A>,
143
- E | LockLostError | MutexBackingError,
144
- R
145
- >;
146
-
147
- /**
148
- * Acquire the lock, waiting if necessary.
149
- * The lock is held until the scope is closed.
150
- * The lock TTL is refreshed automatically while held.
151
- *
152
- * @returns The fiber that refreshes the lock hold. Its lifetime is tied to the scope.
153
- * When the scope closes, the fiber is interrupted and the lock is released.
154
- */
155
- readonly acquire: Effect.Effect<
156
- Fiber.Fiber<void, LockLostError | MutexBackingError>,
157
- LockLostError | MutexBackingError,
158
- Scope.Scope
159
- >;
160
-
161
- /**
162
- * Try to acquire immediately without waiting.
163
- * Returns Some(void) if acquired (lock held until scope closes),
164
- * None if lock was not available.
165
- *
166
- * @returns The fiber that refreshes the lock hold. Its lifetime is tied to the scope.
167
- * When the scope closes, the fiber is interrupted and the lock is released.
168
- */
169
- readonly tryAcquire: Effect.Effect<
170
- Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
171
- LockLostError | MutexBackingError,
172
- Scope.Scope
173
- >;
174
-
175
- /**
176
- * Check if the lock is currently held.
177
- */
178
- readonly isLocked: Effect.Effect<boolean, MutexBackingError>;
179
- }
180
-
181
- // =============================================================================
182
- // Factory
183
- // =============================================================================
184
-
185
- type FullyResolvedConfig = {
186
- ttl: Duration.Duration;
187
- refreshInterval: Duration.Duration;
188
- acquireRetryInterval: Duration.Duration;
189
- backingFailureRetryPolicy: Schedule.Schedule<void>;
190
- };
191
-
192
- function fullyResolveConfig(
193
- config: DistributedMutexConfig
194
- ): FullyResolvedConfig {
195
- const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
196
- const refreshInterval = config.refreshInterval
197
- ? Duration.decode(config.refreshInterval)
198
- : Duration.millis(Duration.toMillis(ttl) / 3);
199
- const acquireRetryInterval = config.acquireRetryInterval
200
- ? Duration.decode(config.acquireRetryInterval)
201
- : DEFAULT_ACQUIRE_RETRY_INTERVAL;
202
- const backingFailureRetryPolicy = config.backingFailureRetryPolicy
203
- ? config.backingFailureRetryPolicy
204
- : DEFAULT_FAILURE_RETRY_POLICY;
205
-
206
- return {
207
- ttl,
208
- refreshInterval,
209
- acquireRetryInterval,
210
- backingFailureRetryPolicy,
211
- };
212
- }
213
-
214
- /**
215
- * Create a distributed mutex for the given key.
216
- */
217
- export const make = (
218
- key: string,
219
- config: DistributedMutexConfig = {}
220
- ): Effect.Effect<DistributedMutex, never, DistributedMutexBacking> =>
221
- Effect.gen(function* () {
222
- const backing = yield* DistributedMutexBacking;
223
-
224
- // Generate unique holder ID for this instance
225
- const holderId = crypto.randomUUID();
226
-
227
- // Resolve config with defaults
228
- const {
229
- ttl,
230
- refreshInterval,
231
- acquireRetryInterval,
232
- backingFailureRetryPolicy,
233
- } = fullyResolveConfig(config);
234
-
235
- const withMutexBackingErrorRetry = <A, E extends { _tag: string }, R>(
236
- effect: Effect.Effect<A, E | MutexBackingError, R>
237
- ) =>
238
- effect.pipe(
239
- Effect.retry({
240
- while: (e) => e._tag === "MutexBackingError",
241
- schedule: backingFailureRetryPolicy,
242
- })
243
- );
244
-
245
- // Keep the lock alive by refreshing TTL periodically.
246
- // This effect runs forever until interrupted (when scope closes).
247
- const keepAlive = Effect.repeat(
248
- Effect.gen(function* () {
249
- const refreshed = yield* backing
250
- .refresh(key, holderId, ttl)
251
- .pipe(withMutexBackingErrorRetry);
252
-
253
- if (!refreshed) {
254
- return yield* new LockLostError({ key });
255
- }
256
- }),
257
- Schedule.spaced(refreshInterval)
258
- ).pipe(Effect.asVoid);
259
-
260
- // Try to acquire immediately, returns Option
261
- const tryAcquire: Effect.Effect<
262
- Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
263
- MutexBackingError,
264
- Scope.Scope
265
- > = Effect.gen(function* () {
266
- const acquired = yield* backing
267
- .tryAcquire(key, holderId, ttl)
268
- .pipe(withMutexBackingErrorRetry);
269
- if (!acquired) {
270
- return Option.none();
271
- }
272
-
273
- // Start keepalive fiber, tied to this scope
274
- const keepAliveFiber = yield* Effect.forkScoped(keepAlive);
275
-
276
- // Add finalizer to release lock when scope closes
277
- yield* Effect.addFinalizer(() =>
278
- backing
279
- .release(key, holderId)
280
- .pipe(withMutexBackingErrorRetry, Effect.ignore)
281
- );
282
-
283
- return Option.some(keepAliveFiber);
284
- });
285
-
286
- // Acquire with retry/timeout, returns void when acquired
287
- const acquire: Effect.Effect<
288
- Fiber.Fiber<void, LockLostError | MutexBackingError>,
289
- MutexBackingError,
290
- Scope.Scope
291
- > =
292
- // Retry until we acquire the lock
293
- Effect.gen(function* () {
294
- const maybeAcquired = yield* tryAcquire;
295
- if (Option.isNone(maybeAcquired)) {
296
- return yield* new NotYetAcquiredError();
297
- }
298
- return maybeAcquired.value;
299
- }).pipe(
300
- Effect.retry({
301
- while: (e) => e._tag === "NotYetAcquiredError",
302
- schedule: Schedule.spaced(acquireRetryInterval),
303
- }),
304
- Effect.catchTag("NotYetAcquiredError", () =>
305
- Effect.dieMessage(
306
- "Invariant violated: `acquire` should never return `NotYetAcquiredError " +
307
- "since it should be caught by the retry which should retry forever until the lock is acquired"
308
- )
309
- )
310
- );
311
-
312
- // Convenience: acquire lock, run effect, release when done
313
- const withLock = <A, E, R>(
314
- effect: Effect.Effect<A, E, R>
315
- ): Effect.Effect<A, E | LockLostError | MutexBackingError, R> =>
316
- Effect.scoped(
317
- Effect.gen(function* () {
318
- const keepAliveFiber = yield* acquire;
319
- const taskFiber = yield* Effect.fork(effect);
320
- return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
321
- })
322
- );
323
-
324
- // Convenience: try to acquire, run effect if successful
325
- const withLockIfAvailable = <A, E, R>(
326
- effect: Effect.Effect<A, E, R>
327
- ): Effect.Effect<
328
- Option.Option<A>,
329
- E | LockLostError | MutexBackingError,
330
- R
331
- > =>
332
- Effect.scoped(
333
- Effect.gen(function* () {
334
- const maybeAcquired = yield* tryAcquire;
335
- if (Option.isNone(maybeAcquired)) {
336
- return Option.none();
337
- }
338
- const keepAliveFiber = maybeAcquired.value;
339
- const taskFiber = yield* Effect.fork(effect.pipe(Effect.asSome));
340
- return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
341
- })
342
- );
343
-
344
- const isLocked: Effect.Effect<boolean, MutexBackingError> = backing
345
- .isLocked(key)
346
- .pipe(withMutexBackingErrorRetry);
347
-
348
- return {
349
- key,
350
- withLock,
351
- withLockIfAvailable,
352
- acquire,
353
- tryAcquire,
354
- isLocked,
355
- } satisfies DistributedMutex;
356
- });