effect-distributed-lock 0.0.2 → 0.0.3
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 +28 -31
- package/package.json +1 -1
- package/src/DistributedMutex.ts +139 -87
- package/src/Errors.ts +1 -25
- package/src/RedisBacking.ts +18 -122
- package/src/index.ts +1 -6
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# effect-distributed-lock
|
|
2
2
|
|
|
3
|
+
*WARNING: This is still in active development, likely has bugs and is subject to change.*
|
|
4
|
+
|
|
3
5
|
A distributed mutex library for [Effect](https://effect.website/) with pluggable backends.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
@@ -7,7 +9,7 @@ A distributed mutex library for [Effect](https://effect.website/) with pluggable
|
|
|
7
9
|
- **Scope-based resource management** — locks are automatically released when the scope closes
|
|
8
10
|
- **Automatic TTL refresh** — keeps locks alive while held, prevents deadlocks if holder crashes
|
|
9
11
|
- **Pluggable backends** — ships with Redis, easy to implement others (etcd, DynamoDB, etc.)
|
|
10
|
-
- **Configurable retry
|
|
12
|
+
- **Configurable retry policies** — control polling interval, TTL, and backing failure retry behavior
|
|
11
13
|
- **Type-safe errors** — tagged errors for precise error handling
|
|
12
14
|
|
|
13
15
|
## Installation
|
|
@@ -33,17 +35,14 @@ const RedisLayer = RedisBacking.layer(redis);
|
|
|
33
35
|
|
|
34
36
|
const program = Effect.gen(function* () {
|
|
35
37
|
const mutex = yield* DistributedMutex.make("my-resource", {
|
|
36
|
-
ttl: "
|
|
37
|
-
|
|
38
|
+
ttl: "10 seconds",
|
|
39
|
+
refreshInterval: "3 seconds",
|
|
40
|
+
acquireRetryInterval: "500 millis",
|
|
41
|
+
backingFailureRetryPolicy: Schedule.exponential("100 millis")),
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
// Lock is held while effect runs, released automatically after
|
|
41
|
-
yield* mutex.withLock(
|
|
42
|
-
Effect.gen(function* () {
|
|
43
|
-
// Critical section - only one process can be here at a time
|
|
44
|
-
yield* doExclusiveWork();
|
|
45
|
-
})
|
|
46
|
-
);
|
|
45
|
+
yield* mutex.withLock(doExclusiveWork);
|
|
47
46
|
});
|
|
48
47
|
|
|
49
48
|
program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
|
|
@@ -57,18 +56,18 @@ program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
|
|
|
57
56
|
const mutex = yield* DistributedMutex.make(key, config);
|
|
58
57
|
```
|
|
59
58
|
|
|
60
|
-
| Config Option
|
|
61
|
-
|
|
|
62
|
-
| `ttl`
|
|
63
|
-
| `refreshInterval`
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
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 |
|
|
66
65
|
|
|
67
66
|
### Using the Mutex
|
|
68
67
|
|
|
69
68
|
#### `withLock` — Acquire, run, release
|
|
70
69
|
|
|
71
|
-
The simplest and recommended way. Acquires the lock, runs your effect, and releases when done:
|
|
70
|
+
The simplest and recommended way. Acquires the lock (waiting indefinitely if needed), runs your effect, and releases when done:
|
|
72
71
|
|
|
73
72
|
```typescript
|
|
74
73
|
yield* mutex.withLock(myEffect);
|
|
@@ -94,13 +93,15 @@ For advanced use cases where you need explicit control over the lock lifecycle:
|
|
|
94
93
|
```typescript
|
|
95
94
|
yield* Effect.scoped(
|
|
96
95
|
Effect.gen(function* () {
|
|
97
|
-
yield* mutex.acquire; // Lock held until scope closes
|
|
98
|
-
yield* doWork
|
|
99
|
-
// Lock automatically released here
|
|
96
|
+
const keepAliveFiber = yield* mutex.acquire; // Lock held until scope closes
|
|
97
|
+
yield* doWork;
|
|
98
|
+
// Lock automatically released + keepalive fiber interrupted here
|
|
100
99
|
})
|
|
101
100
|
);
|
|
102
101
|
```
|
|
103
102
|
|
|
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.
|
|
104
|
+
|
|
104
105
|
#### `isLocked` — Check lock status
|
|
105
106
|
|
|
106
107
|
```typescript
|
|
@@ -113,36 +114,32 @@ All errors are tagged for precise handling with `Effect.catchTag`:
|
|
|
113
114
|
|
|
114
115
|
```typescript
|
|
115
116
|
yield* mutex.withLock(myEffect).pipe(
|
|
116
|
-
Effect.catchTag("AcquireTimeoutError", (e) =>
|
|
117
|
-
Effect.log(`Timed out acquiring lock: ${e.key}`)
|
|
118
|
-
),
|
|
119
117
|
Effect.catchTag("LockLostError", (e) =>
|
|
120
118
|
Effect.log(`Lock was lost while held: ${e.key}`)
|
|
121
119
|
),
|
|
122
|
-
Effect.catchTag("
|
|
120
|
+
Effect.catchTag("MutexBackingError", (e) =>
|
|
123
121
|
Effect.log(`Redis error: ${e.message}`)
|
|
124
122
|
)
|
|
125
123
|
);
|
|
126
124
|
```
|
|
127
125
|
|
|
128
|
-
| Error
|
|
129
|
-
|
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `BackingError` | Error from the backing store (Redis connection, etc) |
|
|
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) |
|
|
133
130
|
|
|
134
131
|
## Custom Backends
|
|
135
132
|
|
|
136
133
|
Implement the `DistributedMutexBacking` interface to use a different store:
|
|
137
134
|
|
|
138
135
|
```typescript
|
|
139
|
-
import { Layer } from "effect";
|
|
136
|
+
import { Duration, Layer } from "effect";
|
|
140
137
|
import { DistributedMutex } from "effect-distributed-lock";
|
|
141
138
|
|
|
142
139
|
const MyCustomBacking = Layer.succeed(DistributedMutex.DistributedMutexBacking, {
|
|
143
|
-
tryAcquire: (key, holderId,
|
|
140
|
+
tryAcquire: (key, holderId, ttl: Duration.Duration) => /* ... */,
|
|
144
141
|
release: (key, holderId) => /* ... */,
|
|
145
|
-
refresh: (key, holderId,
|
|
142
|
+
refresh: (key, holderId, ttl: Duration.Duration) => /* ... */,
|
|
146
143
|
isLocked: (key) => /* ... */,
|
|
147
144
|
getHolder: (key) => /* ... */,
|
|
148
145
|
});
|
package/package.json
CHANGED
package/src/DistributedMutex.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { Context, Duration, Effect, Option, Schedule, Scope } from "effect";
|
|
2
1
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
Context,
|
|
3
|
+
Duration,
|
|
4
|
+
Effect,
|
|
5
|
+
Fiber,
|
|
6
|
+
Option,
|
|
7
|
+
Schedule,
|
|
8
|
+
Scope,
|
|
9
|
+
} from "effect";
|
|
10
|
+
import {
|
|
11
|
+
MutexBackingError,
|
|
5
12
|
LockLostError,
|
|
6
13
|
NotYetAcquiredError,
|
|
7
14
|
} from "./Errors.js";
|
|
@@ -22,8 +29,8 @@ export interface DistributedMutexBacking {
|
|
|
22
29
|
readonly tryAcquire: (
|
|
23
30
|
key: string,
|
|
24
31
|
holderId: string,
|
|
25
|
-
|
|
26
|
-
) => Effect.Effect<boolean,
|
|
32
|
+
ttl: Duration.Duration
|
|
33
|
+
) => Effect.Effect<boolean, MutexBackingError>;
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
36
|
* Release the lock. Only succeeds if we are the current holder.
|
|
@@ -32,7 +39,7 @@ export interface DistributedMutexBacking {
|
|
|
32
39
|
readonly release: (
|
|
33
40
|
key: string,
|
|
34
41
|
holderId: string
|
|
35
|
-
) => Effect.Effect<boolean,
|
|
42
|
+
) => Effect.Effect<boolean, MutexBackingError>;
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* Refresh the TTL on a lock we hold.
|
|
@@ -41,20 +48,20 @@ export interface DistributedMutexBacking {
|
|
|
41
48
|
readonly refresh: (
|
|
42
49
|
key: string,
|
|
43
50
|
holderId: string,
|
|
44
|
-
|
|
45
|
-
) => Effect.Effect<boolean,
|
|
51
|
+
ttl: Duration.Duration
|
|
52
|
+
) => Effect.Effect<boolean, MutexBackingError>;
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* Check if the lock is currently held (by anyone).
|
|
49
56
|
*/
|
|
50
|
-
readonly isLocked: (key: string) => Effect.Effect<boolean,
|
|
57
|
+
readonly isLocked: (key: string) => Effect.Effect<boolean, MutexBackingError>;
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* Get the current holder ID, if any.
|
|
54
61
|
*/
|
|
55
62
|
readonly getHolder: (
|
|
56
63
|
key: string
|
|
57
|
-
) => Effect.Effect<Option.Option<string>,
|
|
64
|
+
) => Effect.Effect<Option.Option<string>, MutexBackingError>;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export const DistributedMutexBacking =
|
|
@@ -84,17 +91,23 @@ export interface DistributedMutexConfig {
|
|
|
84
91
|
* How often to poll when waiting to acquire the lock.
|
|
85
92
|
* @default 100ms
|
|
86
93
|
*/
|
|
87
|
-
readonly
|
|
94
|
+
readonly acquireRetryInterval?: Duration.DurationInput;
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
92
102
|
*/
|
|
93
|
-
readonly
|
|
103
|
+
readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
const DEFAULT_TTL = Duration.seconds(30);
|
|
97
|
-
const
|
|
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);
|
|
98
111
|
|
|
99
112
|
// =============================================================================
|
|
100
113
|
// Distributed Mutex Interface
|
|
@@ -116,11 +129,7 @@ export interface DistributedMutex {
|
|
|
116
129
|
*/
|
|
117
130
|
readonly withLock: <A, E, R>(
|
|
118
131
|
effect: Effect.Effect<A, E, R>
|
|
119
|
-
) => Effect.Effect<
|
|
120
|
-
A,
|
|
121
|
-
E | AcquireTimeoutError | LockLostError | BackingError,
|
|
122
|
-
R
|
|
123
|
-
>;
|
|
132
|
+
) => Effect.Effect<A, E | LockLostError | MutexBackingError, R>;
|
|
124
133
|
|
|
125
134
|
/**
|
|
126
135
|
* Try to acquire the lock immediately without waiting.
|
|
@@ -129,16 +138,23 @@ export interface DistributedMutex {
|
|
|
129
138
|
*/
|
|
130
139
|
readonly withLockIfAvailable: <A, E, R>(
|
|
131
140
|
effect: Effect.Effect<A, E, R>
|
|
132
|
-
) => Effect.Effect<
|
|
141
|
+
) => Effect.Effect<
|
|
142
|
+
Option.Option<A>,
|
|
143
|
+
E | LockLostError | MutexBackingError,
|
|
144
|
+
R
|
|
145
|
+
>;
|
|
133
146
|
|
|
134
147
|
/**
|
|
135
148
|
* Acquire the lock, waiting if necessary.
|
|
136
149
|
* The lock is held until the scope is closed.
|
|
137
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.
|
|
138
154
|
*/
|
|
139
155
|
readonly acquire: Effect.Effect<
|
|
140
|
-
void,
|
|
141
|
-
|
|
156
|
+
Fiber.Fiber<void, LockLostError | MutexBackingError>,
|
|
157
|
+
LockLostError | MutexBackingError,
|
|
142
158
|
Scope.Scope
|
|
143
159
|
>;
|
|
144
160
|
|
|
@@ -146,23 +162,55 @@ export interface DistributedMutex {
|
|
|
146
162
|
* Try to acquire immediately without waiting.
|
|
147
163
|
* Returns Some(void) if acquired (lock held until scope closes),
|
|
148
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.
|
|
149
168
|
*/
|
|
150
169
|
readonly tryAcquire: Effect.Effect<
|
|
151
|
-
Option.Option<void
|
|
152
|
-
LockLostError |
|
|
170
|
+
Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
|
|
171
|
+
LockLostError | MutexBackingError,
|
|
153
172
|
Scope.Scope
|
|
154
173
|
>;
|
|
155
174
|
|
|
156
175
|
/**
|
|
157
176
|
* Check if the lock is currently held.
|
|
158
177
|
*/
|
|
159
|
-
readonly isLocked: Effect.Effect<boolean,
|
|
178
|
+
readonly isLocked: Effect.Effect<boolean, MutexBackingError>;
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
// =============================================================================
|
|
163
182
|
// Factory
|
|
164
183
|
// =============================================================================
|
|
165
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
|
+
|
|
166
214
|
/**
|
|
167
215
|
* Create a distributed mutex for the given key.
|
|
168
216
|
*/
|
|
@@ -177,121 +225,125 @@ export const make = (
|
|
|
177
225
|
const holderId = crypto.randomUUID();
|
|
178
226
|
|
|
179
227
|
// Resolve config with defaults
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
);
|
|
191
244
|
|
|
192
245
|
// Keep the lock alive by refreshing TTL periodically.
|
|
193
246
|
// This effect runs forever until interrupted (when scope closes).
|
|
194
247
|
const keepAlive = Effect.repeat(
|
|
195
248
|
Effect.gen(function* () {
|
|
196
|
-
const refreshed = yield* backing
|
|
249
|
+
const refreshed = yield* backing
|
|
250
|
+
.refresh(key, holderId, ttl)
|
|
251
|
+
.pipe(withMutexBackingErrorRetry);
|
|
252
|
+
|
|
197
253
|
if (!refreshed) {
|
|
198
254
|
return yield* new LockLostError({ key });
|
|
199
255
|
}
|
|
200
256
|
}),
|
|
201
257
|
Schedule.spaced(refreshInterval)
|
|
202
|
-
);
|
|
258
|
+
).pipe(Effect.asVoid);
|
|
203
259
|
|
|
204
260
|
// Try to acquire immediately, returns Option
|
|
205
261
|
const tryAcquire: Effect.Effect<
|
|
206
|
-
Option.Option<void
|
|
207
|
-
|
|
262
|
+
Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
|
|
263
|
+
MutexBackingError,
|
|
208
264
|
Scope.Scope
|
|
209
265
|
> = Effect.gen(function* () {
|
|
210
|
-
const acquired = yield* backing
|
|
266
|
+
const acquired = yield* backing
|
|
267
|
+
.tryAcquire(key, holderId, ttl)
|
|
268
|
+
.pipe(withMutexBackingErrorRetry);
|
|
211
269
|
if (!acquired) {
|
|
212
270
|
return Option.none();
|
|
213
271
|
}
|
|
214
272
|
|
|
215
273
|
// Start keepalive fiber, tied to this scope
|
|
216
|
-
yield* Effect.forkScoped(keepAlive);
|
|
274
|
+
const keepAliveFiber = yield* Effect.forkScoped(keepAlive);
|
|
217
275
|
|
|
218
276
|
// Add finalizer to release lock when scope closes
|
|
219
277
|
yield* Effect.addFinalizer(() =>
|
|
220
|
-
backing
|
|
278
|
+
backing
|
|
279
|
+
.release(key, holderId)
|
|
280
|
+
.pipe(withMutexBackingErrorRetry, Effect.ignore)
|
|
221
281
|
);
|
|
222
282
|
|
|
223
|
-
return Option.some(
|
|
283
|
+
return Option.some(keepAliveFiber);
|
|
224
284
|
});
|
|
225
285
|
|
|
226
286
|
// Acquire with retry/timeout, returns void when acquired
|
|
227
287
|
const acquire: Effect.Effect<
|
|
228
|
-
void,
|
|
229
|
-
|
|
288
|
+
Fiber.Fiber<void, LockLostError | MutexBackingError>,
|
|
289
|
+
MutexBackingError,
|
|
230
290
|
Scope.Scope
|
|
231
|
-
> =
|
|
232
|
-
// Build retry schedule with optional timeout
|
|
233
|
-
const schedule = Option.match(acquireTimeout, {
|
|
234
|
-
onNone: () => Schedule.spaced(retryInterval),
|
|
235
|
-
onSome: (timeout) =>
|
|
236
|
-
Schedule.spaced(retryInterval).pipe(Schedule.upTo(timeout)),
|
|
237
|
-
});
|
|
238
|
-
|
|
291
|
+
> =
|
|
239
292
|
// Retry until we acquire the lock
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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),
|
|
246
303
|
}),
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
new AcquireTimeoutError({
|
|
253
|
-
key,
|
|
254
|
-
timeoutMs: Option.match(acquireTimeout, {
|
|
255
|
-
onNone: () => -1,
|
|
256
|
-
onSome: Duration.toMillis,
|
|
257
|
-
}),
|
|
258
|
-
})
|
|
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
|
+
)
|
|
259
309
|
)
|
|
260
310
|
);
|
|
261
|
-
});
|
|
262
311
|
|
|
263
312
|
// Convenience: acquire lock, run effect, release when done
|
|
264
313
|
const withLock = <A, E, R>(
|
|
265
314
|
effect: Effect.Effect<A, E, R>
|
|
266
|
-
): Effect.Effect<
|
|
267
|
-
A,
|
|
268
|
-
E | AcquireTimeoutError | LockLostError | BackingError,
|
|
269
|
-
R
|
|
270
|
-
> =>
|
|
315
|
+
): Effect.Effect<A, E | LockLostError | MutexBackingError, R> =>
|
|
271
316
|
Effect.scoped(
|
|
272
317
|
Effect.gen(function* () {
|
|
273
|
-
yield* acquire;
|
|
274
|
-
|
|
318
|
+
const keepAliveFiber = yield* acquire;
|
|
319
|
+
const taskFiber = yield* Effect.fork(effect);
|
|
320
|
+
return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
|
|
275
321
|
})
|
|
276
322
|
);
|
|
277
323
|
|
|
278
324
|
// Convenience: try to acquire, run effect if successful
|
|
279
325
|
const withLockIfAvailable = <A, E, R>(
|
|
280
326
|
effect: Effect.Effect<A, E, R>
|
|
281
|
-
): Effect.Effect<
|
|
327
|
+
): Effect.Effect<
|
|
328
|
+
Option.Option<A>,
|
|
329
|
+
E | LockLostError | MutexBackingError,
|
|
330
|
+
R
|
|
331
|
+
> =>
|
|
282
332
|
Effect.scoped(
|
|
283
333
|
Effect.gen(function* () {
|
|
284
334
|
const maybeAcquired = yield* tryAcquire;
|
|
285
335
|
if (Option.isNone(maybeAcquired)) {
|
|
286
|
-
return Option.none
|
|
336
|
+
return Option.none();
|
|
287
337
|
}
|
|
288
|
-
const
|
|
289
|
-
|
|
338
|
+
const keepAliveFiber = maybeAcquired.value;
|
|
339
|
+
const taskFiber = yield* Effect.fork(effect.pipe(Effect.asSome));
|
|
340
|
+
return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
|
|
290
341
|
})
|
|
291
342
|
);
|
|
292
343
|
|
|
293
|
-
const isLocked: Effect.Effect<boolean,
|
|
294
|
-
|
|
344
|
+
const isLocked: Effect.Effect<boolean, MutexBackingError> = backing
|
|
345
|
+
.isLocked(key)
|
|
346
|
+
.pipe(withMutexBackingErrorRetry);
|
|
295
347
|
|
|
296
348
|
return {
|
|
297
349
|
key,
|
package/src/Errors.ts
CHANGED
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
import { Data } from "effect";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Base error for all distributed mutex errors
|
|
5
|
-
*/
|
|
6
|
-
export class DistributedMutexError extends Data.TaggedError(
|
|
7
|
-
"DistributedMutexError"
|
|
8
|
-
)<{
|
|
9
|
-
readonly message: string;
|
|
10
|
-
readonly cause?: unknown;
|
|
11
|
-
}> {}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Failed to acquire the lock within the timeout period
|
|
15
|
-
*/
|
|
16
|
-
export class AcquireTimeoutError extends Data.TaggedError(
|
|
17
|
-
"AcquireTimeoutError"
|
|
18
|
-
)<{
|
|
19
|
-
readonly key: string;
|
|
20
|
-
readonly timeoutMs: number;
|
|
21
|
-
}> {
|
|
22
|
-
get message() {
|
|
23
|
-
return `Failed to acquire lock "${this.key}" within ${this.timeoutMs}ms`;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
3
|
/**
|
|
28
4
|
* The lock was lost (TTL expired while we thought we held it)
|
|
29
5
|
*/
|
|
@@ -38,7 +14,7 @@ export class LockLostError extends Data.TaggedError("LockLostError")<{
|
|
|
38
14
|
/**
|
|
39
15
|
* Error from the backing store (Redis, etc.)
|
|
40
16
|
*/
|
|
41
|
-
export class
|
|
17
|
+
export class MutexBackingError extends Data.TaggedError("MutexBackingError")<{
|
|
42
18
|
readonly operation: string;
|
|
43
19
|
readonly cause: unknown;
|
|
44
20
|
}> {
|
package/src/RedisBacking.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Effect, Layer, Option } from "effect";
|
|
1
|
+
import { Duration, Effect, Layer, Option } from "effect";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
3
|
import { DistributedMutexBacking } from "./DistributedMutex.js";
|
|
4
|
-
import {
|
|
4
|
+
import { MutexBackingError } from "./Errors.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Lua script for atomic lock acquisition.
|
|
@@ -70,8 +70,8 @@ export const layer = (
|
|
|
70
70
|
const tryAcquire = (
|
|
71
71
|
key: string,
|
|
72
72
|
holderId: string,
|
|
73
|
-
|
|
74
|
-
): Effect.Effect<boolean,
|
|
73
|
+
ttl: Duration.Duration
|
|
74
|
+
): Effect.Effect<boolean, MutexBackingError> =>
|
|
75
75
|
Effect.tryPromise({
|
|
76
76
|
try: async () => {
|
|
77
77
|
const result = await redis.eval(
|
|
@@ -79,17 +79,18 @@ export const layer = (
|
|
|
79
79
|
1,
|
|
80
80
|
prefixKey(key),
|
|
81
81
|
holderId,
|
|
82
|
-
|
|
82
|
+
Duration.toMillis(ttl).toString()
|
|
83
83
|
);
|
|
84
84
|
return result === 1;
|
|
85
85
|
},
|
|
86
|
-
catch: (cause) =>
|
|
86
|
+
catch: (cause) =>
|
|
87
|
+
new MutexBackingError({ operation: "tryAcquire", cause }),
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
const release = (
|
|
90
91
|
key: string,
|
|
91
92
|
holderId: string
|
|
92
|
-
): Effect.Effect<boolean,
|
|
93
|
+
): Effect.Effect<boolean, MutexBackingError> =>
|
|
93
94
|
Effect.tryPromise({
|
|
94
95
|
try: async () => {
|
|
95
96
|
const result = await redis.eval(
|
|
@@ -100,14 +101,14 @@ export const layer = (
|
|
|
100
101
|
);
|
|
101
102
|
return result === 1;
|
|
102
103
|
},
|
|
103
|
-
catch: (cause) => new
|
|
104
|
+
catch: (cause) => new MutexBackingError({ operation: "release", cause }),
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
const refresh = (
|
|
107
108
|
key: string,
|
|
108
109
|
holderId: string,
|
|
109
|
-
|
|
110
|
-
): Effect.Effect<boolean,
|
|
110
|
+
ttl: Duration.Duration
|
|
111
|
+
): Effect.Effect<boolean, MutexBackingError> =>
|
|
111
112
|
Effect.tryPromise({
|
|
112
113
|
try: async () => {
|
|
113
114
|
const result = await redis.eval(
|
|
@@ -115,31 +116,32 @@ export const layer = (
|
|
|
115
116
|
1,
|
|
116
117
|
prefixKey(key),
|
|
117
118
|
holderId,
|
|
118
|
-
|
|
119
|
+
Duration.toMillis(ttl).toString()
|
|
119
120
|
);
|
|
120
121
|
return result === 1;
|
|
121
122
|
},
|
|
122
|
-
catch: (cause) => new
|
|
123
|
+
catch: (cause) => new MutexBackingError({ operation: "refresh", cause }),
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
const isLocked = (key: string): Effect.Effect<boolean,
|
|
126
|
+
const isLocked = (key: string): Effect.Effect<boolean, MutexBackingError> =>
|
|
126
127
|
Effect.tryPromise({
|
|
127
128
|
try: async () => {
|
|
128
129
|
const exists = await redis.exists(prefixKey(key));
|
|
129
130
|
return exists === 1;
|
|
130
131
|
},
|
|
131
|
-
catch: (cause) => new
|
|
132
|
+
catch: (cause) => new MutexBackingError({ operation: "isLocked", cause }),
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
const getHolder = (
|
|
135
136
|
key: string
|
|
136
|
-
): Effect.Effect<Option.Option<string>,
|
|
137
|
+
): Effect.Effect<Option.Option<string>, MutexBackingError> =>
|
|
137
138
|
Effect.tryPromise({
|
|
138
139
|
try: async () => {
|
|
139
140
|
const holder = await redis.get(prefixKey(key));
|
|
140
141
|
return holder ? Option.some(holder) : Option.none();
|
|
141
142
|
},
|
|
142
|
-
catch: (cause) =>
|
|
143
|
+
catch: (cause) =>
|
|
144
|
+
new MutexBackingError({ operation: "getHolder", cause }),
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
return Layer.succeed(DistributedMutexBacking, {
|
|
@@ -150,109 +152,3 @@ export const layer = (
|
|
|
150
152
|
getHolder,
|
|
151
153
|
});
|
|
152
154
|
};
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Create a Redis backing from a connection URL.
|
|
156
|
-
* This creates and manages the Redis connection lifecycle.
|
|
157
|
-
*/
|
|
158
|
-
export const layerFromUrl = (
|
|
159
|
-
url: string,
|
|
160
|
-
keyPrefix = "dmutex:"
|
|
161
|
-
): Layer.Layer<DistributedMutexBacking, BackingError> =>
|
|
162
|
-
Layer.scoped(
|
|
163
|
-
DistributedMutexBacking,
|
|
164
|
-
Effect.gen(function* () {
|
|
165
|
-
const redis = new Redis(url);
|
|
166
|
-
|
|
167
|
-
// Ensure cleanup on scope close
|
|
168
|
-
yield* Effect.addFinalizer(() =>
|
|
169
|
-
Effect.promise(() => redis.quit().catch(() => {}))
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
const prefixKey = (key: string) => `${keyPrefix}${key}`;
|
|
173
|
-
|
|
174
|
-
const tryAcquire = (
|
|
175
|
-
key: string,
|
|
176
|
-
holderId: string,
|
|
177
|
-
ttlMs: number
|
|
178
|
-
): Effect.Effect<boolean, BackingError> =>
|
|
179
|
-
Effect.tryPromise({
|
|
180
|
-
try: async () => {
|
|
181
|
-
const result = await redis.eval(
|
|
182
|
-
ACQUIRE_SCRIPT,
|
|
183
|
-
1,
|
|
184
|
-
prefixKey(key),
|
|
185
|
-
holderId,
|
|
186
|
-
ttlMs.toString()
|
|
187
|
-
);
|
|
188
|
-
return result === 1;
|
|
189
|
-
},
|
|
190
|
-
catch: (cause) =>
|
|
191
|
-
new BackingError({ operation: "tryAcquire", cause }),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const release = (
|
|
195
|
-
key: string,
|
|
196
|
-
holderId: string
|
|
197
|
-
): Effect.Effect<boolean, BackingError> =>
|
|
198
|
-
Effect.tryPromise({
|
|
199
|
-
try: async () => {
|
|
200
|
-
const result = await redis.eval(
|
|
201
|
-
RELEASE_SCRIPT,
|
|
202
|
-
1,
|
|
203
|
-
prefixKey(key),
|
|
204
|
-
holderId
|
|
205
|
-
);
|
|
206
|
-
return result === 1;
|
|
207
|
-
},
|
|
208
|
-
catch: (cause) => new BackingError({ operation: "release", cause }),
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
const refresh = (
|
|
212
|
-
key: string,
|
|
213
|
-
holderId: string,
|
|
214
|
-
ttlMs: number
|
|
215
|
-
): Effect.Effect<boolean, BackingError> =>
|
|
216
|
-
Effect.tryPromise({
|
|
217
|
-
try: async () => {
|
|
218
|
-
const result = await redis.eval(
|
|
219
|
-
REFRESH_SCRIPT,
|
|
220
|
-
1,
|
|
221
|
-
prefixKey(key),
|
|
222
|
-
holderId,
|
|
223
|
-
ttlMs.toString()
|
|
224
|
-
);
|
|
225
|
-
return result === 1;
|
|
226
|
-
},
|
|
227
|
-
catch: (cause) => new BackingError({ operation: "refresh", cause }),
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const isLocked = (key: string): Effect.Effect<boolean, BackingError> =>
|
|
231
|
-
Effect.tryPromise({
|
|
232
|
-
try: async () => {
|
|
233
|
-
const exists = await redis.exists(prefixKey(key));
|
|
234
|
-
return exists === 1;
|
|
235
|
-
},
|
|
236
|
-
catch: (cause) => new BackingError({ operation: "isLocked", cause }),
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
const getHolder = (
|
|
240
|
-
key: string
|
|
241
|
-
): Effect.Effect<Option.Option<string>, BackingError> =>
|
|
242
|
-
Effect.tryPromise({
|
|
243
|
-
try: async () => {
|
|
244
|
-
const holder = await redis.get(prefixKey(key));
|
|
245
|
-
return holder ? Option.some(holder) : Option.none();
|
|
246
|
-
},
|
|
247
|
-
catch: (cause) => new BackingError({ operation: "getHolder", cause }),
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
tryAcquire,
|
|
252
|
-
release,
|
|
253
|
-
refresh,
|
|
254
|
-
isLocked,
|
|
255
|
-
getHolder,
|
|
256
|
-
} satisfies DistributedMutexBacking;
|
|
257
|
-
})
|
|
258
|
-
);
|
package/src/index.ts
CHANGED
|
@@ -39,12 +39,7 @@
|
|
|
39
39
|
// Core module (namespace with types and functions)
|
|
40
40
|
export * as DistributedMutex from "./DistributedMutex.js";
|
|
41
41
|
// Errors
|
|
42
|
-
export {
|
|
43
|
-
AcquireTimeoutError,
|
|
44
|
-
BackingError,
|
|
45
|
-
DistributedMutexError,
|
|
46
|
-
LockLostError,
|
|
47
|
-
} from "./Errors.js";
|
|
42
|
+
export { MutexBackingError, LockLostError } from "./Errors.js";
|
|
48
43
|
|
|
49
44
|
// Redis backing
|
|
50
45
|
export * as RedisBacking from "./RedisBacking.js";
|