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.
- package/README.md +156 -56
- package/examples/index.ts +49 -21
- package/package.json +2 -2
- package/redis-semaphore/.codeclimate.yml +5 -0
- package/redis-semaphore/.fossa.yml +14 -0
- package/redis-semaphore/.github/dependabot.yml +6 -0
- package/redis-semaphore/.github/workflows/branches.yml +39 -0
- package/redis-semaphore/.github/workflows/pull-requests.yml +35 -0
- package/redis-semaphore/.mocharc.yaml +6 -0
- package/redis-semaphore/.prettierrc +6 -0
- package/redis-semaphore/.snyk +4 -0
- package/redis-semaphore/.yarnrc.yml +2 -0
- package/redis-semaphore/CHANGELOG.md +70 -0
- package/redis-semaphore/Dockerfile +5 -0
- package/redis-semaphore/LICENSE +21 -0
- package/redis-semaphore/README.md +445 -0
- package/redis-semaphore/docker-compose.yml +31 -0
- package/redis-semaphore/eslint.config.mjs +73 -0
- package/redis-semaphore/package.json +79 -0
- package/redis-semaphore/setup-redis-servers.sh +2 -0
- package/redis-semaphore/src/Lock.ts +172 -0
- package/redis-semaphore/src/RedisMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedisMutex.ts +45 -0
- package/redis-semaphore/src/RedisSemaphore.ts +49 -0
- package/redis-semaphore/src/RedlockMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedlockMutex.ts +52 -0
- package/redis-semaphore/src/RedlockSemaphore.ts +49 -0
- package/redis-semaphore/src/errors/LostLockError.ts +1 -0
- package/redis-semaphore/src/errors/TimeoutError.ts +1 -0
- package/redis-semaphore/src/index.ts +23 -0
- package/redis-semaphore/src/misc.ts +12 -0
- package/redis-semaphore/src/multiSemaphore/acquire/index.ts +53 -0
- package/redis-semaphore/src/multiSemaphore/acquire/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/refresh/index.ts +32 -0
- package/redis-semaphore/src/multiSemaphore/refresh/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/release/index.ts +22 -0
- package/redis-semaphore/src/multiSemaphore/release/lua.ts +17 -0
- package/redis-semaphore/src/mutex/acquire.ts +42 -0
- package/redis-semaphore/src/mutex/refresh.ts +37 -0
- package/redis-semaphore/src/mutex/release.ts +30 -0
- package/redis-semaphore/src/redlockMultiSemaphore/acquire.ts +56 -0
- package/redis-semaphore/src/redlockMultiSemaphore/refresh.ts +68 -0
- package/redis-semaphore/src/redlockMultiSemaphore/release.ts +19 -0
- package/redis-semaphore/src/redlockMutex/acquire.ts +54 -0
- package/redis-semaphore/src/redlockMutex/refresh.ts +53 -0
- package/redis-semaphore/src/redlockMutex/release.ts +19 -0
- package/redis-semaphore/src/redlockSemaphore/acquire.ts +55 -0
- package/redis-semaphore/src/redlockSemaphore/refresh.ts +60 -0
- package/redis-semaphore/src/redlockSemaphore/release.ts +18 -0
- package/redis-semaphore/src/semaphore/acquire/index.ts +52 -0
- package/redis-semaphore/src/semaphore/acquire/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/refresh/index.ts +31 -0
- package/redis-semaphore/src/semaphore/refresh/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/release.ts +14 -0
- package/redis-semaphore/src/types.ts +63 -0
- package/redis-semaphore/src/utils/createEval.ts +45 -0
- package/redis-semaphore/src/utils/index.ts +13 -0
- package/redis-semaphore/src/utils/redlock.ts +7 -0
- package/redis-semaphore/test/init.test.ts +9 -0
- package/redis-semaphore/test/redisClient.ts +82 -0
- package/redis-semaphore/test/setup.ts +6 -0
- package/redis-semaphore/test/shell.test.ts +15 -0
- package/redis-semaphore/test/shell.ts +48 -0
- package/redis-semaphore/test/src/Lock.test.ts +37 -0
- package/redis-semaphore/test/src/RedisMultiSemaphore.test.ts +425 -0
- package/redis-semaphore/test/src/RedisMutex.test.ts +334 -0
- package/redis-semaphore/test/src/RedisSemaphore.test.ts +367 -0
- package/redis-semaphore/test/src/RedlockMultiSemaphore.test.ts +671 -0
- package/redis-semaphore/test/src/RedlockMutex.test.ts +328 -0
- package/redis-semaphore/test/src/RedlockSemaphore.test.ts +579 -0
- package/redis-semaphore/test/src/index.test.ts +22 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/index.test.ts +51 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/internal.test.ts +67 -0
- package/redis-semaphore/test/src/multiSemaphore/refresh/index.test.ts +52 -0
- package/redis-semaphore/test/src/multiSemaphore/release/index.test.ts +18 -0
- package/redis-semaphore/test/src/mutex/acquire.test.ts +78 -0
- package/redis-semaphore/test/src/mutex/refresh.test.ts +22 -0
- package/redis-semaphore/test/src/mutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/redlockMutex/acquire.test.ts +90 -0
- package/redis-semaphore/test/src/redlockMutex/refresh.test.ts +27 -0
- package/redis-semaphore/test/src/redlockMutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/semaphore/acquire/index.test.ts +49 -0
- package/redis-semaphore/test/src/semaphore/acquire/internal.test.ts +65 -0
- package/redis-semaphore/test/src/semaphore/refresh/index.test.ts +44 -0
- package/redis-semaphore/test/src/semaphore/release.test.ts +18 -0
- package/redis-semaphore/test/src/utils/eval.test.ts +22 -0
- package/redis-semaphore/test/src/utils/index.test.ts +19 -0
- package/redis-semaphore/test/src/utils/redlock.test.ts +31 -0
- package/redis-semaphore/test/unhandledRejection.ts +28 -0
- package/redis-semaphore/tsconfig.build-commonjs.json +9 -0
- package/redis-semaphore/tsconfig.build-es.json +9 -0
- package/redis-semaphore/tsconfig.json +11 -0
- package/redis-semaphore/yarn.lock +5338 -0
- package/src/Backing.ts +87 -0
- package/src/DistributedSemaphore.ts +448 -0
- package/src/Errors.ts +3 -15
- package/src/RedisBacking.ts +165 -59
- package/src/index.ts +28 -12
- 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,
|
|
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
|
|
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
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
45
|
-
yield*
|
|
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
|
|
58
|
+
### Creating a Semaphore
|
|
54
59
|
|
|
55
60
|
```typescript
|
|
56
|
-
const
|
|
61
|
+
const sem = yield* DistributedSemaphore.make(key, config);
|
|
57
62
|
```
|
|
58
63
|
|
|
59
|
-
| Config Option
|
|
60
|
-
|
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
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
|
|
72
|
+
### Using the Semaphore
|
|
67
73
|
|
|
68
|
-
#### `
|
|
74
|
+
#### `withPermits` — Acquire, run, release
|
|
69
75
|
|
|
70
|
-
The simplest and recommended way. Acquires
|
|
76
|
+
The simplest and recommended way. Acquires permits (waiting if needed), runs your effect, and releases when done:
|
|
71
77
|
|
|
72
78
|
```typescript
|
|
73
|
-
|
|
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
|
-
#### `
|
|
86
|
+
#### `withPermitsIfAvailable` — Non-blocking acquire
|
|
77
87
|
|
|
78
|
-
Tries to acquire immediately without waiting. Returns `Option.some(result)` if successful, `Option.none()` if
|
|
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*
|
|
91
|
+
const result = yield* myEffect.pipe(sem.withPermitsIfAvailable(1));
|
|
82
92
|
if (Option.isSome(result)) {
|
|
83
|
-
console.log("Got the
|
|
93
|
+
console.log("Got the permit!", result.value);
|
|
84
94
|
} else {
|
|
85
|
-
console.log("
|
|
95
|
+
console.log("No permits available, skipping");
|
|
86
96
|
}
|
|
87
97
|
```
|
|
88
98
|
|
|
89
|
-
#### `
|
|
99
|
+
#### `take` / `tryTake` — Manual scope control
|
|
90
100
|
|
|
91
|
-
For advanced use cases where you need explicit control over the
|
|
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*
|
|
106
|
+
const keepAliveFiber = yield* sem.take(2); // 2 permits held until scope closes
|
|
97
107
|
yield* doWork;
|
|
98
|
-
//
|
|
108
|
+
// Permits automatically released + keepalive fiber interrupted here
|
|
99
109
|
})
|
|
100
110
|
);
|
|
101
111
|
```
|
|
102
112
|
|
|
103
|
-
Both `
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
193
|
+
yield* myEffect.pipe(
|
|
194
|
+
sem.withPermits(1),
|
|
117
195
|
Effect.catchTag("LockLostError", (e) =>
|
|
118
|
-
Effect.log(`
|
|
196
|
+
Effect.log(`Permits were lost: ${e.key}`)
|
|
119
197
|
),
|
|
120
|
-
Effect.catchTag("
|
|
198
|
+
Effect.catchTag("SemaphoreBackingError", (e) =>
|
|
121
199
|
Effect.log(`Redis error: ${e.message}`)
|
|
122
200
|
)
|
|
123
201
|
);
|
|
124
202
|
```
|
|
125
203
|
|
|
126
|
-
| Error
|
|
127
|
-
|
|
|
128
|
-
| `LockLostError`
|
|
129
|
-
| `
|
|
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 `
|
|
226
|
+
Implement the `DistributedSemaphoreBacking` interface to use a different store:
|
|
134
227
|
|
|
135
228
|
```typescript
|
|
136
|
-
import { Duration, Layer } from "effect";
|
|
137
|
-
import {
|
|
138
|
-
|
|
139
|
-
const MyCustomBacking = Layer.succeed(
|
|
140
|
-
tryAcquire: (key, holderId, ttl
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
151
|
-
2. **Keepalive**: A background fiber refreshes the TTL periodically
|
|
152
|
-
3. **Release**: Atomically
|
|
153
|
-
4. **
|
|
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
|
|
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 {
|
|
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
|
|
17
|
+
// Example 1: Using withPermits for a critical section (mutex behavior)
|
|
18
18
|
const example1 = Effect.gen(function* () {
|
|
19
|
-
yield* Console.log("=== Example 1:
|
|
19
|
+
yield* Console.log("=== Example 1: Mutex with withPermits(1) ===");
|
|
20
20
|
|
|
21
|
-
const mutex = yield*
|
|
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.
|
|
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
|
|
37
|
+
// Example 2: Using withPermitsIfAvailable (non-blocking)
|
|
38
38
|
const example2 = Effect.gen(function* () {
|
|
39
|
-
yield* Console.log("\n=== Example 2:
|
|
39
|
+
yield* Console.log("\n=== Example 2: withPermitsIfAvailable ===");
|
|
40
40
|
|
|
41
|
-
const mutex = yield*
|
|
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.
|
|
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
|
-
|
|
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:
|
|
60
|
+
// Example 3: Semaphore with multiple permits
|
|
56
61
|
const example3 = Effect.gen(function* () {
|
|
57
|
-
yield* Console.log("\n=== Example 3:
|
|
62
|
+
yield* Console.log("\n=== Example 3: Semaphore with limit=5 ===");
|
|
58
63
|
|
|
59
|
-
const
|
|
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.
|
|
68
|
-
yield* Console.log("
|
|
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
|
-
//
|
|
98
|
+
// Permits are automatically released when scope closes
|
|
72
99
|
})
|
|
73
100
|
);
|
|
74
101
|
|
|
75
|
-
yield* Console.log("Scope closed,
|
|
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.
|
|
4
|
-
"description": "A distributed
|
|
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,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,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,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
|