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/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # effect-distributed-lock
2
2
 
3
- *WARNING: This is still in active development, likely has bugs and is subject to change.*
3
+ *WARNING: This is still in active development, possibly has bugs (distributed systems are hard!), and is subject to change.*
4
4
 
5
- A distributed mutex library for [Effect](https://effect.website/) with pluggable backends.
5
+ A distributed semaphore library for [Effect](https://effect.website/) with pluggable backends.
6
+
7
+ It's like the built in `Effect.Semaphore`, but asynchronously distributed across multiple processes/services!
6
8
 
7
9
  ## Features
8
10
 
9
- - **Scope-based resource management** — locks are automatically released when the scope closes
10
- - **Automatic TTL refresh** — keeps locks alive while held, prevents deadlocks if holder crashes
11
- - **Pluggable backends** — ships with Redis, easy to implement others (etcd, DynamoDB, etc.)
11
+ - **Distributed semaphore** — control concurrent access across multiple processes/services
12
+ - **Scope-based resource management** — permits are automatically released when the scope closes
13
+ - **Automatic TTL refresh** — keeps permits alive while held, prevents deadlocks if holder crashes
14
+ - **Pluggable backends** — ships with Redis (single-instance), easy to implement others
12
15
  - **Configurable retry policies** — control polling interval, TTL, and backing failure retry behavior
13
16
  - **Type-safe errors** — tagged errors for precise error handling
14
17
 
@@ -26,23 +29,25 @@ npm install ioredis
26
29
  ## Quick Start
27
30
 
28
31
  ```typescript
29
- import { Effect } from "effect";
32
+ import { Effect, Schedule } from "effect";
30
33
  import Redis from "ioredis";
31
- import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
34
+ import { DistributedSemaphore, RedisBacking } from "effect-distributed-lock";
32
35
 
33
36
  const redis = new Redis(process.env.REDIS_URL);
34
37
  const RedisLayer = RedisBacking.layer(redis);
35
38
 
36
39
  const program = Effect.gen(function* () {
37
- const mutex = yield* DistributedMutex.make("my-resource", {
40
+ // Create a semaphore that allows 5 concurrent operations
41
+ const sem = yield* DistributedSemaphore.make("my-resource", {
42
+ limit: 5,
38
43
  ttl: "10 seconds",
39
44
  refreshInterval: "3 seconds",
40
45
  acquireRetryInterval: "500 millis",
41
- backingFailureRetryPolicy: Schedule.exponential("100 millis")),
46
+ backingFailureRetryPolicy: Schedule.exponential("100 millis"),
42
47
  });
43
48
 
44
- // Lock is held while effect runs, released automatically after
45
- yield* mutex.withLock(doExclusiveWork);
49
+ // Acquire 2 permits, run effect, release automatically after
50
+ yield* doWork.pipe(sem.withPermits(2));
46
51
  });
47
52
 
48
53
  program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
@@ -50,62 +55,134 @@ program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
50
55
 
51
56
  ## API
52
57
 
53
- ### Creating a Mutex
58
+ ### Creating a Semaphore
54
59
 
55
60
  ```typescript
56
- const mutex = yield* DistributedMutex.make(key, config);
61
+ const sem = yield* DistributedSemaphore.make(key, config);
57
62
  ```
58
63
 
59
- | Config Option | Type | Default | Description |
60
- | ---------------------- | ------------------ | ------------ | --------------------------------------------------- |
61
- | `ttl` | `DurationInput` | `30 seconds` | Lock TTL (auto-releases if holder crashes) |
62
- | `refreshInterval` | `DurationInput` | `ttl / 3` | How often to refresh TTL while holding |
63
- | `acquireRetryInterval` | `DurationInput` | `100ms` | Polling interval when waiting to acquire |
64
- | `backingFailureRetryPolicy` | `Schedule<void>` | `100ms` | Retry schedule for backing store failures |
64
+ | Config Option | Type | Default | Description |
65
+ | ---------------------------- | ---------------- | ------------ | ------------------------------------------ |
66
+ | `limit` | `number` | `1` | Max permits (1 = mutex behavior) |
67
+ | `ttl` | `DurationInput` | `30 seconds` | Permit TTL (auto-releases if holder crashes) |
68
+ | `refreshInterval` | `DurationInput` | `ttl / 3` | How often to refresh TTL while holding |
69
+ | `acquireRetryInterval` | `DurationInput` | `100ms` | Polling interval when waiting to acquire |
70
+ | `backingFailureRetryPolicy` | `Schedule<void>` | `100ms` | Retry schedule for backing store failures |
65
71
 
66
- ### Using the Mutex
72
+ ### Using the Semaphore
67
73
 
68
- #### `withLock` — Acquire, run, release
74
+ #### `withPermits` — Acquire, run, release
69
75
 
70
- The simplest and recommended way. Acquires the lock (waiting indefinitely if needed), runs your effect, and releases when done:
76
+ The simplest and recommended way. Acquires permits (waiting if needed), runs your effect, and releases when done:
71
77
 
72
78
  ```typescript
73
- yield* mutex.withLock(myEffect);
79
+ // Acquire 2 permits out of limit
80
+ yield* myEffect.pipe(sem.withPermits(2));
81
+
82
+ // For mutex behavior (limit=1), use withPermits(1)
83
+ yield* criticalSection.pipe(mutex.withPermits(1));
74
84
  ```
75
85
 
76
- #### `withLockIfAvailable` — Non-blocking acquire
86
+ #### `withPermitsIfAvailable` — Non-blocking acquire
77
87
 
78
- Tries to acquire immediately without waiting. Returns `Option.some(result)` if successful, `Option.none()` if lock is held:
88
+ Tries to acquire immediately without waiting. Returns `Option.some(result)` if successful, `Option.none()` if not enough permits:
79
89
 
80
90
  ```typescript
81
- const result = yield* mutex.withLockIfAvailable(myEffect);
91
+ const result = yield* myEffect.pipe(sem.withPermitsIfAvailable(1));
82
92
  if (Option.isSome(result)) {
83
- console.log("Got the lock!", result.value);
93
+ console.log("Got the permit!", result.value);
84
94
  } else {
85
- console.log("Lock was busy, skipping");
95
+ console.log("No permits available, skipping");
86
96
  }
87
97
  ```
88
98
 
89
- #### `acquire` / `tryAcquire` — Manual scope control
99
+ #### `take` / `tryTake` — Manual scope control
90
100
 
91
- For advanced use cases where you need explicit control over the lock lifecycle:
101
+ For advanced use cases where you need explicit control over the permit lifecycle:
92
102
 
93
103
  ```typescript
94
104
  yield* Effect.scoped(
95
105
  Effect.gen(function* () {
96
- const keepAliveFiber = yield* mutex.acquire; // Lock held until scope closes
106
+ const keepAliveFiber = yield* sem.take(2); // 2 permits held until scope closes
97
107
  yield* doWork;
98
- // Lock automatically released + keepalive fiber interrupted here
108
+ // Permits automatically released + keepalive fiber interrupted here
99
109
  })
100
110
  );
101
111
  ```
102
112
 
103
- Both `acquire` and `tryAcquire` return the keepalive fiber that refreshes the lock TTL. Errors related to the keep alive (losing the lock or backing store failure) are propagated to the fiber.
113
+ Both `take` and `tryTake` return the keepalive fiber that refreshes the permit TTL. Errors from the keepalive (losing permits or backing store failure) are propagated through the fiber.
114
+
115
+ ### Acquire Options
116
+
117
+ All acquire methods (`withPermits`, `withPermitsIfAvailable`, `take`, `tryTake`) accept an optional second parameter for advanced use cases:
118
+
119
+ ```typescript
120
+ yield* myEffect.pipe(sem.withPermits(1, { identifier: "my-custom-id" }));
121
+ ```
122
+
123
+ | Option | Type | Default | Description |
124
+ | ------------------- | --------- | -------------------- | ------------------------------------------------ |
125
+ | `identifier` | `string` | `crypto.randomUUID()` | Unique ID for this permit holder |
126
+ | `acquiredExternally`| `boolean` | `false` | Assume permits already held, use refresh to verify |
127
+
128
+ #### Custom Identifiers
104
129
 
105
- #### `isLocked` Check lock status
130
+ By default, a random UUID is generated for each acquire. Override this for:
131
+ - **Debugging/observability**: Use meaningful identifiers to trace lock holders
132
+ - **Cross-process handoff**: Share identifiers between processes
106
133
 
107
134
  ```typescript
108
- const locked = yield* mutex.isLocked;
135
+ // Custom identifier for debugging
136
+ yield* myEffect.pipe(sem.withPermits(1, { identifier: "worker-1-job-123" }));
137
+ ```
138
+
139
+ ⚠️ **Warning**: Identifiers must be unique across concurrent holders. Using the same identifier from different processes will cause them to be treated as the same holder.
140
+
141
+ #### Resuming After Crash (`acquiredExternally`)
142
+
143
+ Use `acquiredExternally: true` to resume ownership of permits that were acquired previously but not properly released (e.g., after a process crash). This uses `refresh` instead of `acquire` to verify ownership.
144
+
145
+ ```typescript
146
+ // Store identifier persistently before doing work
147
+ const identifier = crypto.randomUUID();
148
+ yield* saveToDatabase({ jobId, lockIdentifier: identifier });
149
+
150
+ yield* Effect.gen(function* () {
151
+ yield* doWork();
152
+ yield* deleteFromDatabase(jobId);
153
+ }).pipe(sem.withPermits(1, { identifier }));
154
+
155
+ // === Later, after crash recovery ===
156
+ const { lockIdentifier } = yield* loadFromDatabase(jobId);
157
+
158
+ // Check if we still hold the lock (TTL hasn't expired)
159
+ const result = yield* Effect.gen(function* () {
160
+ yield* resumeWork();
161
+ yield* deleteFromDatabase(jobId);
162
+ }).pipe(
163
+ sem.withPermitsIfAvailable(1, {
164
+ identifier: lockIdentifier,
165
+ acquiredExternally: true
166
+ })
167
+ );
168
+
169
+ if (Option.isNone(result)) {
170
+ // Lock expired, need to re-acquire normally
171
+ yield* restartWork().pipe(sem.withPermits(1));
172
+ }
173
+ ```
174
+
175
+ This is useful for:
176
+ - **Crash recovery**: Resume work if you crashed while holding permits
177
+ - **Process restart**: Check if your previous lock is still valid
178
+
179
+ ⚠️ **Unsafe**: If the identifier is wrong or the lock expired, `tryTake`/`withPermitsIfAvailable` return `None`, while `take`/`withPermits` keep retrying forever (waiting for permits that will never come).
180
+
181
+ #### `currentCount` — Check held permits
182
+
183
+ ```typescript
184
+ const held = yield* sem.currentCount; // Number of permits currently held
185
+ const available = sem.limit - held; // Number of permits available
109
186
  ```
110
187
 
111
188
  ## Error Handling
@@ -113,44 +190,67 @@ const locked = yield* mutex.isLocked;
113
190
  All errors are tagged for precise handling with `Effect.catchTag`:
114
191
 
115
192
  ```typescript
116
- yield* mutex.withLock(myEffect).pipe(
193
+ yield* myEffect.pipe(
194
+ sem.withPermits(1),
117
195
  Effect.catchTag("LockLostError", (e) =>
118
- Effect.log(`Lock was lost while held: ${e.key}`)
196
+ Effect.log(`Permits were lost: ${e.key}`)
119
197
  ),
120
- Effect.catchTag("MutexBackingError", (e) =>
198
+ Effect.catchTag("SemaphoreBackingError", (e) =>
121
199
  Effect.log(`Redis error: ${e.message}`)
122
200
  )
123
201
  );
124
202
  ```
125
203
 
126
- | Error | Description |
127
- | ------------------- | ---------------------------------------------------- |
128
- | `LockLostError` | Lock TTL expired while we thought we held it |
129
- | `MutexBackingError` | Error from the backing store (Redis connection, etc) |
204
+ | Error | Description |
205
+ | ----------------------- | ---------------------------------------------------- |
206
+ | `LockLostError` | Permit TTL expired while we thought we held it |
207
+ | `SemaphoreBackingError` | Error from the backing store (Redis connection, etc) |
208
+
209
+ ## Redis Backing (Single-Instance Only)
210
+
211
+ The provided Redis backing is designed for **single-instance Redis only**. It does not implement the [Redlock algorithm](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/) and should not be used with Redis Cluster or Redis Sentinel when you need strong distributed locking guarantees.
212
+
213
+ ```typescript
214
+ import Redis from "ioredis";
215
+ import { RedisBacking } from "effect-distributed-lock";
216
+
217
+ // Single Redis instance
218
+ const redis = new Redis("redis://localhost:6379");
219
+ const RedisLayer = RedisBacking.layer(redis, "my-prefix:");
220
+ ```
221
+
222
+ For multi-instance Redis deployments requiring Redlock, you'll need to implement a custom backing.
130
223
 
131
224
  ## Custom Backends
132
225
 
133
- Implement the `DistributedMutexBacking` interface to use a different store:
226
+ Implement the `DistributedSemaphoreBacking` interface to use a different store:
134
227
 
135
228
  ```typescript
136
- import { Duration, Layer } from "effect";
137
- import { DistributedMutex } from "effect-distributed-lock";
138
-
139
- const MyCustomBacking = Layer.succeed(DistributedMutex.DistributedMutexBacking, {
140
- tryAcquire: (key, holderId, ttl: Duration.Duration) => /* ... */,
141
- release: (key, holderId) => /* ... */,
142
- refresh: (key, holderId, ttl: Duration.Duration) => /* ... */,
143
- isLocked: (key) => /* ... */,
144
- getHolder: (key) => /* ... */,
229
+ import { Duration, Effect, Layer, Option } from "effect";
230
+ import { Backing, DistributedSemaphoreBacking } from "effect-distributed-lock";
231
+
232
+ const MyCustomBacking = Layer.succeed(DistributedSemaphoreBacking, {
233
+ tryAcquire: (key, holderId, ttl, limit, permits) =>
234
+ Effect.succeed(true), // Try to acquire permits
235
+
236
+ release: (key, holderId, permits) =>
237
+ Effect.succeed(permits), // Release permits, return count released
238
+
239
+ refresh: (key, holderId, ttl, limit, permits) =>
240
+ Effect.succeed(true), // Refresh TTL, return false if lost
241
+
242
+ getCount: (key, ttl) =>
243
+ Effect.succeed(0), // Return number of permits currently held
145
244
  });
146
245
  ```
147
246
 
148
247
  ## How It Works
149
248
 
150
- 1. **Acquire**: Atomically sets the lock key if not exists (Redis: `SET key value NX PX ttl`)
151
- 2. **Keepalive**: A background fiber refreshes the TTL periodically while the lock is held
152
- 3. **Release**: Atomically deletes the key only if we're still the holder (Lua script for atomicity)
153
- 4. **Crash safety**: If the holder crashes, the TTL expires and another process can acquire
249
+ 1. **Acquire**: Atomically adds permits to a sorted set if there's room (Redis: Lua script with `ZADD`)
250
+ 2. **Keepalive**: A background fiber refreshes the TTL periodically by updating timestamps
251
+ 3. **Release**: Atomically removes permits from the sorted set (Lua script with `ZREM`)
252
+ 4. **Expiration**: Expired entries (based on TTL) are cleaned up on each operation
253
+ 5. **Crash safety**: If the holder crashes, permits expire and become available
154
254
 
155
255
  ## License
156
256
 
package/examples/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Example usage of the distributed mutex library.
2
+ * Example usage of the distributed semaphore library.
3
3
  *
4
4
  * Run with: bun run example.ts
5
5
  * Requires REDIS_URL environment variable.
6
6
  */
7
- import { Effect, Console, Duration } from "effect";
7
+ import { Effect, Console, Duration, Option } from "effect";
8
8
  import Redis from "ioredis";
9
- import { DistributedMutex, RedisBacking } from "../src/index.ts";
9
+ import { DistributedSemaphore, RedisBacking } from "../src/index.ts";
10
10
 
11
11
  // Create Redis client
12
12
  const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
@@ -14,16 +14,16 @@ const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
14
14
  // Create the Redis backing layer
15
15
  const RedisLayer = RedisBacking.layer(redis, "example:");
16
16
 
17
- // Example 1: Using withLock for a critical section
17
+ // Example 1: Using withPermits for a critical section (mutex behavior)
18
18
  const example1 = Effect.gen(function* () {
19
- yield* Console.log("=== Example 1: withLock ===");
19
+ yield* Console.log("=== Example 1: Mutex with withPermits(1) ===");
20
20
 
21
- const mutex = yield* DistributedMutex.make("my-resource", {
21
+ const mutex = yield* DistributedSemaphore.make("my-resource", {
22
+ limit: 1, // Acts as a mutex
22
23
  ttl: Duration.seconds(10),
23
- acquireTimeout: Duration.seconds(5),
24
24
  });
25
25
 
26
- yield* mutex.withLock(
26
+ yield* mutex.withPermits(1)(
27
27
  Effect.gen(function* () {
28
28
  yield* Console.log("Lock acquired! Doing critical work...");
29
29
  yield* Effect.sleep(2000);
@@ -34,45 +34,72 @@ const example1 = Effect.gen(function* () {
34
34
  yield* Console.log("Lock released automatically");
35
35
  });
36
36
 
37
- // Example 2: Using tryAcquire (non-blocking)
37
+ // Example 2: Using withPermitsIfAvailable (non-blocking)
38
38
  const example2 = Effect.gen(function* () {
39
- yield* Console.log("\n=== Example 2: withLockIfAvailable ===");
39
+ yield* Console.log("\n=== Example 2: withPermitsIfAvailable ===");
40
40
 
41
- const mutex = yield* DistributedMutex.make("another-resource", {
41
+ const mutex = yield* DistributedSemaphore.make("another-resource", {
42
+ limit: 1,
42
43
  ttl: Duration.seconds(10),
43
44
  });
44
45
 
45
- const result = yield* mutex.withLockIfAvailable(
46
+ const result = yield* mutex.withPermitsIfAvailable(1)(
46
47
  Effect.gen(function* () {
47
48
  yield* Console.log("Got the lock without waiting!");
48
49
  return "success";
49
50
  })
50
51
  );
51
52
 
52
- yield* Console.log(`Result: ${JSON.stringify(result)}`);
53
+ if (Option.isSome(result)) {
54
+ yield* Console.log(`Result: ${result.value}`);
55
+ } else {
56
+ yield* Console.log("Could not acquire lock immediately");
57
+ }
53
58
  });
54
59
 
55
- // Example 3: Manual scope management with acquire()
60
+ // Example 3: Semaphore with multiple permits
56
61
  const example3 = Effect.gen(function* () {
57
- yield* Console.log("\n=== Example 3: Manual acquire with Scope ===");
62
+ yield* Console.log("\n=== Example 3: Semaphore with limit=5 ===");
58
63
 
59
- const mutex = yield* DistributedMutex.make("manual-resource", {
64
+ const sem = yield* DistributedSemaphore.make("pool-resource", {
65
+ limit: 5, // Allow 5 concurrent operations
66
+ ttl: Duration.seconds(10),
67
+ });
68
+
69
+ // Acquire 2 permits out of 5
70
+ yield* sem.withPermits(2)(
71
+ Effect.gen(function* () {
72
+ yield* Console.log("Acquired 2 permits (3 still available)");
73
+ const count = yield* sem.currentCount;
74
+ yield* Console.log(`Current active permits: ${count}`);
75
+ yield* Effect.sleep(1000);
76
+ })
77
+ );
78
+
79
+ yield* Console.log("Released 2 permits");
80
+ });
81
+
82
+ // Example 4: Manual scope management with take()
83
+ const example4 = Effect.gen(function* () {
84
+ yield* Console.log("\n=== Example 4: Manual acquire with Scope ===");
85
+
86
+ const mutex = yield* DistributedSemaphore.make("manual-resource", {
87
+ limit: 1,
60
88
  ttl: Duration.seconds(10),
61
- acquireTimeout: Duration.seconds(5),
62
89
  });
63
90
 
64
91
  // Using Effect.scoped to manage the lock lifecycle
65
92
  yield* Effect.scoped(
66
93
  Effect.gen(function* () {
67
- yield* mutex.acquire;
68
- yield* Console.log("Lock acquired via acquire()");
94
+ yield* mutex.take(1);
95
+ yield* Console.log("Permit acquired via take(1)");
69
96
  yield* Effect.sleep(1000);
70
97
  yield* Console.log("About to exit scope...");
71
- // Lock is automatically released when scope closes
98
+ // Permits are automatically released when scope closes
72
99
  })
73
100
  );
74
101
 
75
- yield* Console.log("Scope closed, lock released");
102
+ yield* Console.log("Scope closed, permit released");
76
103
  });
77
104
 
78
105
  // Run all examples
@@ -80,6 +107,7 @@ const main = Effect.gen(function* () {
80
107
  yield* example1;
81
108
  yield* example2;
82
109
  yield* example3;
110
+ yield* example4;
83
111
  yield* Console.log("\n✓ All examples completed!");
84
112
  }).pipe(
85
113
  Effect.ensuring(Effect.promise(() => redis.quit())),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "effect-distributed-lock",
3
- "version": "0.0.3",
4
- "description": "A distributed mutex library for Effect with pluggable backends",
3
+ "version": "0.0.5",
4
+ "description": "A distributed semaphore library for Effect with pluggable backends",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -0,0 +1,5 @@
1
+ version: '2'
2
+ plugins:
3
+ eslint:
4
+ enabled: true
5
+ channel: 'eslint-6'
@@ -0,0 +1,14 @@
1
+ # Generated by FOSSA CLI (https://github.com/fossas/fossa-cli)
2
+ # Visit https://fossa.com to learn more
3
+
4
+ version: 2
5
+ cli:
6
+ server: https://app.fossa.com
7
+ fetcher: custom
8
+ project: git@github.com:swarthy/redis-semaphore.git
9
+ analyze:
10
+ modules:
11
+ - name: .
12
+ type: npm
13
+ target: .
14
+ path: .
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: 'npm'
4
+ directory: '/'
5
+ schedule:
6
+ interval: 'monthly'
@@ -0,0 +1,39 @@
1
+ name: CI (push)
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ integration-test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ node-version: [18.x, 20.x, 22.x]
16
+
17
+ env:
18
+ COVERALLS_REPO_TOKEN: '${{ secrets.COVERALLS_REPO_TOKEN }}'
19
+ COVERALLS_GIT_BRANCH: '${{ github.ref }}'
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - name: Enable Corepack
24
+ run: corepack enable
25
+
26
+ - name: Use Node.js ${{ matrix.node-version }}
27
+ uses: actions/setup-node@v4
28
+ with:
29
+ node-version: ${{ matrix.node-version }}
30
+ cache: 'yarn'
31
+
32
+ - run: yarn install --immutable
33
+
34
+ - run: docker compose up -d redis1 redis2 redis3
35
+ - run: docker compose run waiter
36
+
37
+ - run: yarn build
38
+ - run: yarn lint
39
+ - run: yarn test-ci-with-coverage
@@ -0,0 +1,35 @@
1
+ name: CI (PR)
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - master
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ integration-test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ node-version: [18.x, 20.x, 22.x]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Enable Corepack
20
+ run: corepack enable
21
+
22
+ - name: Use Node.js ${{ matrix.node-version }}
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: ${{ matrix.node-version }}
26
+ cache: 'yarn'
27
+
28
+ - run: yarn install --immutable
29
+
30
+ - run: docker compose up -d redis1 redis2 redis3
31
+ - run: docker compose run waiter
32
+
33
+ - run: yarn build
34
+ - run: yarn lint
35
+ - run: yarn test
@@ -0,0 +1,6 @@
1
+ extension: ts
2
+ recursive: true
3
+ timeout: 5s
4
+ require:
5
+ - '@swc-node/register'
6
+ - test/setup.ts
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "arrowParens": "avoid"
6
+ }
@@ -0,0 +1,4 @@
1
+ # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
2
+ version: v1.13.1
3
+ ignore: {}
4
+ patch: {}
@@ -0,0 +1,2 @@
1
+ nodeLinker: node-modules
2
+ defaultSemverRangePrefix: ''
@@ -0,0 +1,70 @@
1
+ ### redis-semaphore@5.6.2
2
+ - Fixed implicit import from `src`
3
+ - Removed `src` folder from NPM package
4
+
5
+ ### redis-semaphore@5.6.1
6
+ - Removed `module` field from `package.json`
7
+
8
+ ### redis-semaphore@5.6.0
9
+ - Added interface compatible client support (ex. `ioredis-mock`)
10
+ - Removed `instanceof Redis` validation in constructor
11
+ - `ioredis` marked as optional peerDependency, explicit `ioredis` install is required now
12
+
13
+ ### redis-semaphore@5.5.1
14
+ - Fix race condition for refresh started before release and finished after release
15
+
16
+ ### redis-semaphore@5.5.0
17
+
18
+ - Added `identifier` constructor option.
19
+ - Added `acquiredExternally` constructor option.
20
+ - Option `externallyAcquiredIdentifier` **DEPRECATED**.
21
+ - Option `identifierSuffix` **DEPRECATED**.
22
+
23
+ ### redis-semaphore@5.4.0
24
+
25
+ - Added `identifierSuffix` option, usefull for tracing app instance which locked resource
26
+
27
+ ### redis-semaphore@5.3.1
28
+
29
+ - Fixed reacquire expired resource in refresh
30
+
31
+ ### redis-semaphore@5.3.0
32
+
33
+ - Added `stopRefresh` method
34
+ - Added `externallyAcquiredIdentifier` optional constructor option
35
+ - Removed `uuid` dependency
36
+
37
+ ### redis-semaphore@5.2.0
38
+
39
+ - Added `acquireAttemptsLimit` method
40
+
41
+ ### redis-semaphore@5.1.0
42
+
43
+ - Added `tryAcquire`
44
+
45
+ ### redis-semaphore@5.0.0
46
+
47
+ - **Breadking change:** Drop Node.js v10.x, v12.x support
48
+ - Added `ioredis@5` support
49
+
50
+ ### redis-semaphore@4.1.0
51
+
52
+ - Added `.isAcquired` property on all locks
53
+ - Added `onLostLock` constructor option. By default throws unhandled error.
54
+
55
+ ### redis-semaphore@4.0.0
56
+
57
+ - **Breaking change:** `Mutex`, `Semaphore`, `MultiSemaphore` not longer support `Cluster`. For multi-node case use `Redlock*` instead.
58
+ - Added `RedlockMutex`, `RedlockSemaphore`, `RedlockMultiSemaphore`
59
+ - Internals refactored
60
+
61
+ ### redis-semaphore@3.2.0
62
+
63
+ - Added `MultiSemaphore`
64
+
65
+ ### redis-semaphore@3.0.0
66
+
67
+ - **Breaking change:** `FairSemaphore` has been removed. Use `Semaphore` instead (has the same "fairness")
68
+ - the `acquire` method in `Semaphore` no longer returns a boolean. Instead, it throws an error if it cannot acquire, and if it doesn't throw, you can assume it worked.
69
+ - Internal code has been cleaned up
70
+ - Added more test, include synthetic node unsynchroned clocks
@@ -0,0 +1,5 @@
1
+ FROM node:alpine
2
+ RUN npm i -g @swarthy/wait-for@2.0.2
3
+ VOLUME /app
4
+ WORKDIR /app
5
+ USER node