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
@@ -1,19 +1,50 @@
1
- import { Duration, Effect, Layer, Option } from "effect";
1
+ import { Duration, Effect, Layer } from "effect";
2
2
  import { Redis } from "ioredis";
3
- import { DistributedMutexBacking } from "./DistributedMutex.js";
4
- import { MutexBackingError } from "./Errors.js";
3
+ import {
4
+ DistributedSemaphoreBacking,
5
+ SemaphoreBackingError,
6
+ } from "./Backing.js";
5
7
 
6
8
  /**
7
- * Lua script for atomic lock acquisition.
8
- * SET key value NX PX ttl - set only if not exists, with TTL
9
+ * Lua script for atomic semaphore acquisition.
10
+ *
11
+ * Uses a sorted set where:
12
+ * - Each member is `holderId_permitIndex` (e.g., "abc123_0", "abc123_1")
13
+ * - Score is the acquisition timestamp
14
+ * - Expired entries are removed before checking capacity
15
+ *
16
+ * Arguments:
17
+ * - KEYS[1]: the semaphore key
18
+ * - ARGV[1]: limit (max permits)
19
+ * - ARGV[2]: permits to acquire
20
+ * - ARGV[3]: holderId
21
+ * - ARGV[4]: ttlMs (lock timeout in ms)
22
+ * - ARGV[5]: now (current timestamp in ms)
23
+ *
24
+ * Returns 1 if acquired, 0 if not enough permits available.
9
25
  */
10
26
  const ACQUIRE_SCRIPT = `
11
27
  local key = KEYS[1]
12
- local holderId = ARGV[1]
13
- local ttlMs = tonumber(ARGV[2])
28
+ local limit = tonumber(ARGV[1])
29
+ local permits = tonumber(ARGV[2])
30
+ local holderId = ARGV[3]
31
+ local ttlMs = tonumber(ARGV[4])
32
+ local now = tonumber(ARGV[5])
33
+ local expiredTimestamp = now - ttlMs
14
34
 
15
- local result = redis.call('SET', key, holderId, 'NX', 'PX', ttlMs)
16
- if result then
35
+ -- Remove expired entries
36
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
37
+
38
+ -- Check if there's room for the requested permits
39
+ if (redis.call('zcard', key) + permits) <= limit then
40
+ -- Add all permits with current timestamp
41
+ local args = {}
42
+ for i = 0, permits - 1 do
43
+ table.insert(args, now)
44
+ table.insert(args, holderId .. '_' .. i)
45
+ end
46
+ redis.call('zadd', key, unpack(args))
47
+ redis.call('pexpire', key, ttlMs)
17
48
  return 1
18
49
  else
19
50
  return 0
@@ -21,34 +52,68 @@ end
21
52
  `;
22
53
 
23
54
  /**
24
- * Lua script for atomic lock release.
25
- * Only deletes if we are the current holder.
55
+ * Lua script for atomic release.
56
+ *
57
+ * Removes all permits held by this holder.
58
+ *
59
+ * Arguments:
60
+ * - KEYS[1]: the semaphore key
61
+ * - ARGV[1]: permits to release
62
+ * - ARGV[2]: holderId
63
+ *
64
+ * Returns the number of permits released.
26
65
  */
27
66
  const RELEASE_SCRIPT = `
28
67
  local key = KEYS[1]
29
- local holderId = ARGV[1]
68
+ local permits = tonumber(ARGV[1])
69
+ local holderId = ARGV[2]
70
+ local args = {}
30
71
 
31
- local currentHolder = redis.call('GET', key)
32
- if currentHolder == holderId then
33
- redis.call('DEL', key)
34
- return 1
35
- else
36
- return 0
72
+ for i = 0, permits - 1 do
73
+ table.insert(args, holderId .. '_' .. i)
37
74
  end
75
+
76
+ return redis.call('zrem', key, unpack(args))
38
77
  `;
39
78
 
40
79
  /**
41
80
  * Lua script for atomic TTL refresh.
42
- * Only refreshes if we are the current holder.
81
+ *
82
+ * Updates the timestamp (score) for all permits held by this holder.
83
+ * Returns 0 if the holder doesn't have any permits (lock was lost).
84
+ *
85
+ * Arguments:
86
+ * - KEYS[1]: the semaphore key
87
+ * - ARGV[1]: limit (for consistency, though not strictly needed for refresh)
88
+ * - ARGV[2]: permits
89
+ * - ARGV[3]: holderId
90
+ * - ARGV[4]: ttlMs
91
+ * - ARGV[5]: now
92
+ *
93
+ * Returns 1 if refreshed, 0 if permits were lost.
43
94
  */
44
95
  const REFRESH_SCRIPT = `
45
96
  local key = KEYS[1]
46
- local holderId = ARGV[1]
47
- local ttlMs = tonumber(ARGV[2])
97
+ local limit = tonumber(ARGV[1])
98
+ local permits = tonumber(ARGV[2])
99
+ local holderId = ARGV[3]
100
+ local ttlMs = tonumber(ARGV[4])
101
+ local now = tonumber(ARGV[5])
102
+ local expiredTimestamp = now - ttlMs
48
103
 
49
- local currentHolder = redis.call('GET', key)
50
- if currentHolder == holderId then
51
- redis.call('PEXPIRE', key, ttlMs)
104
+ -- Remove expired entries
105
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
106
+
107
+ -- Check if we still hold the first permit (indicator that we still own it)
108
+ if redis.call('zscore', key, holderId .. '_0') then
109
+ -- Update all permits with new timestamp
110
+ local args = {}
111
+ for i = 0, permits - 1 do
112
+ table.insert(args, now)
113
+ table.insert(args, holderId .. '_' .. i)
114
+ end
115
+ redis.call('zadd', key, unpack(args))
116
+ redis.call('pexpire', key, ttlMs)
52
117
  return 1
53
118
  else
54
119
  return 0
@@ -56,99 +121,140 @@ end
56
121
  `;
57
122
 
58
123
  /**
59
- * Create a Redis-backed distributed mutex backing layer.
124
+ * Lua script to get the current count of active permits.
125
+ *
126
+ * Arguments:
127
+ * - KEYS[1]: the semaphore key
128
+ * - ARGV[1]: ttlMs
129
+ * - ARGV[2]: now
130
+ *
131
+ * Returns the number of active (non-expired) permits.
132
+ */
133
+ const GET_COUNT_SCRIPT = `
134
+ local key = KEYS[1]
135
+ local ttlMs = tonumber(ARGV[1])
136
+ local now = tonumber(ARGV[2])
137
+ local expiredTimestamp = now - ttlMs
138
+
139
+ -- Remove expired entries
140
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
141
+
142
+ return redis.call('zcard', key)
143
+ `;
144
+
145
+ /**
146
+ * Create a Redis-backed distributed semaphore backing layer.
60
147
  *
61
- * @param redis - An ioredis client instance
62
- * @param keyPrefix - Optional prefix for all keys (default: "dmutex:")
148
+ * **Important:** This implementation is for single-instance Redis only.
149
+ * It does not implement the Redlock algorithm and should not be used with
150
+ * Redis Cluster or Redis Sentinel for distributed locking guarantees.
151
+ * For multi-instance Redis, consider implementing a Redlock-based backing.
152
+ *
153
+ * @param redis - An ioredis client instance (single instance, not cluster)
154
+ * @param keyPrefix - Optional prefix for all keys (default: "dsem:")
63
155
  */
64
156
  export const layer = (
65
157
  redis: Redis,
66
- keyPrefix = "dmutex:"
67
- ): Layer.Layer<DistributedMutexBacking> => {
158
+ keyPrefix = "semaphore:"
159
+ ): Layer.Layer<DistributedSemaphoreBacking> => {
68
160
  const prefixKey = (key: string) => `${keyPrefix}${key}`;
69
161
 
70
162
  const tryAcquire = (
71
163
  key: string,
72
164
  holderId: string,
73
- ttl: Duration.Duration
74
- ): Effect.Effect<boolean, MutexBackingError> =>
165
+ ttl: Duration.Duration,
166
+ limit: number,
167
+ permits: number
168
+ ): Effect.Effect<boolean, SemaphoreBackingError> =>
75
169
  Effect.tryPromise({
76
170
  try: async () => {
171
+ const now = Date.now();
77
172
  const result = await redis.eval(
78
173
  ACQUIRE_SCRIPT,
79
174
  1,
80
175
  prefixKey(key),
176
+ limit.toString(),
177
+ permits.toString(),
81
178
  holderId,
82
- Duration.toMillis(ttl).toString()
179
+ Duration.toMillis(ttl).toString(),
180
+ now.toString()
83
181
  );
84
182
  return result === 1;
85
183
  },
86
184
  catch: (cause) =>
87
- new MutexBackingError({ operation: "tryAcquire", cause }),
185
+ new SemaphoreBackingError({ operation: "tryAcquire", cause }),
88
186
  });
89
187
 
90
188
  const release = (
91
189
  key: string,
92
- holderId: string
93
- ): Effect.Effect<boolean, MutexBackingError> =>
190
+ holderId: string,
191
+ permits: number
192
+ ): Effect.Effect<number, SemaphoreBackingError> =>
94
193
  Effect.tryPromise({
95
194
  try: async () => {
96
195
  const result = await redis.eval(
97
196
  RELEASE_SCRIPT,
98
197
  1,
99
198
  prefixKey(key),
199
+ permits.toString(),
100
200
  holderId
101
201
  );
102
- return result === 1;
202
+ return result as number;
103
203
  },
104
- catch: (cause) => new MutexBackingError({ operation: "release", cause }),
204
+ catch: (cause) =>
205
+ new SemaphoreBackingError({ operation: "release", cause }),
105
206
  });
106
207
 
107
208
  const refresh = (
108
209
  key: string,
109
210
  holderId: string,
110
- ttl: Duration.Duration
111
- ): Effect.Effect<boolean, MutexBackingError> =>
211
+ ttl: Duration.Duration,
212
+ limit: number,
213
+ permits: number
214
+ ): Effect.Effect<boolean, SemaphoreBackingError> =>
112
215
  Effect.tryPromise({
113
216
  try: async () => {
217
+ const now = Date.now();
114
218
  const result = await redis.eval(
115
219
  REFRESH_SCRIPT,
116
220
  1,
117
221
  prefixKey(key),
222
+ limit.toString(),
223
+ permits.toString(),
118
224
  holderId,
119
- Duration.toMillis(ttl).toString()
225
+ Duration.toMillis(ttl).toString(),
226
+ now.toString()
120
227
  );
121
228
  return result === 1;
122
229
  },
123
- catch: (cause) => new MutexBackingError({ operation: "refresh", cause }),
124
- });
125
-
126
- const isLocked = (key: string): Effect.Effect<boolean, MutexBackingError> =>
127
- Effect.tryPromise({
128
- try: async () => {
129
- const exists = await redis.exists(prefixKey(key));
130
- return exists === 1;
131
- },
132
- catch: (cause) => new MutexBackingError({ operation: "isLocked", cause }),
230
+ catch: (cause) =>
231
+ new SemaphoreBackingError({ operation: "refresh", cause }),
133
232
  });
134
233
 
135
- const getHolder = (
136
- key: string
137
- ): Effect.Effect<Option.Option<string>, MutexBackingError> =>
234
+ const getCount = (
235
+ key: string,
236
+ ttl: Duration.Duration
237
+ ): Effect.Effect<number, SemaphoreBackingError> =>
138
238
  Effect.tryPromise({
139
239
  try: async () => {
140
- const holder = await redis.get(prefixKey(key));
141
- return holder ? Option.some(holder) : Option.none();
240
+ const now = Date.now();
241
+ const result = await redis.eval(
242
+ GET_COUNT_SCRIPT,
243
+ 1,
244
+ prefixKey(key),
245
+ Duration.toMillis(ttl).toString(),
246
+ now.toString()
247
+ );
248
+ return result as number;
142
249
  },
143
250
  catch: (cause) =>
144
- new MutexBackingError({ operation: "getHolder", cause }),
251
+ new SemaphoreBackingError({ operation: "getCount", cause }),
145
252
  });
146
253
 
147
- return Layer.succeed(DistributedMutexBacking, {
254
+ return Layer.succeed(DistributedSemaphoreBacking, {
148
255
  tryAcquire,
149
256
  release,
150
257
  refresh,
151
- isLocked,
152
- getHolder,
258
+ getCount,
153
259
  });
154
260
  };
package/src/index.ts CHANGED
@@ -1,30 +1,38 @@
1
1
  /**
2
- * Effect Distributed Lock
2
+ * Effect Distributed Semaphore
3
3
  *
4
- * A distributed mutex library for Effect with pluggable backends.
4
+ * A distributed semaphore library for Effect with pluggable backends.
5
+ * Implements a multi-semaphore that can be used to implement:
6
+ * - Mutex (limit=1, permits=1)
7
+ * - Semaphore (limit=N, permits=1)
8
+ * - Multi-semaphore (limit=N, permits=M)
5
9
  *
6
10
  * @example
7
11
  * ```ts
8
- * import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
12
+ * import { DistributedSemaphore, RedisBacking } from "effect-distributed-lock";
9
13
  * import { Effect } from "effect";
10
14
  * import Redis from "ioredis";
11
15
  *
12
16
  * const redis = new Redis(process.env.REDIS_URL);
13
17
  *
14
18
  * const program = Effect.gen(function* () {
15
- * // Create a mutex for a specific resource
16
- * const mutex = yield* DistributedMutex.make("my-resource-lock", {
19
+ * // Create a semaphore that allows 5 concurrent operations
20
+ * const sem = yield* DistributedSemaphore.make("my-resource", {
21
+ * limit: 5,
17
22
  * ttl: "30 seconds",
18
- * acquireTimeout: "10 seconds",
19
23
  * });
20
24
  *
21
- * // Use the lock
22
- * yield* mutex.withLock(
25
+ * // Acquire 2 permits, run effect, release when done
26
+ * yield* sem.withPermits(2)(
23
27
  * Effect.gen(function* () {
24
- * // Critical section - only one process can be here at a time
25
- * yield* doSomethingExclusive();
28
+ * // Only 2 of the 5 slots are used
29
+ * yield* doSomethingLimited();
26
30
  * })
27
31
  * );
32
+ *
33
+ * // For mutex behavior, use limit=1 and withPermits(1)
34
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
35
+ * yield* mutex.withPermits(1)(criticalSection);
28
36
  * });
29
37
  *
30
38
  * program.pipe(
@@ -36,10 +44,18 @@
36
44
  * @module
37
45
  */
38
46
 
47
+ // Backing interface
48
+ export * as Backing from "./Backing.js";
49
+ export {
50
+ DistributedSemaphoreBacking,
51
+ SemaphoreBackingError,
52
+ } from "./Backing.js";
53
+
39
54
  // Core module (namespace with types and functions)
40
- export * as DistributedMutex from "./DistributedMutex.js";
55
+ export * as DistributedSemaphore from "./DistributedSemaphore.js";
56
+
41
57
  // Errors
42
- export { MutexBackingError, LockLostError } from "./Errors.js";
58
+ export { LockLostError } from "./Errors.js";
43
59
 
44
60
  // Redis backing
45
61
  export * as RedisBacking from "./RedisBacking.js";