effect-distributed-lock 0.0.4 → 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 +66 -0
- package/package.json +1 -1
- package/src/DistributedSemaphore.ts +97 -33
- package/src/RedisBacking.ts +1 -1
package/README.md
CHANGED
|
@@ -112,6 +112,72 @@ yield* Effect.scoped(
|
|
|
112
112
|
|
|
113
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
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
|
|
129
|
+
|
|
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
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
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
|
+
|
|
115
181
|
#### `currentCount` — Check held permits
|
|
116
182
|
|
|
117
183
|
```typescript
|
package/package.json
CHANGED
|
@@ -40,9 +40,9 @@ export interface DistributedSemaphoreConfig {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* How often to poll when waiting to acquire permits.
|
|
43
|
-
* @default
|
|
43
|
+
* @default Schedule.spaced(Duration.millis(100))
|
|
44
44
|
*/
|
|
45
|
-
readonly
|
|
45
|
+
readonly acquireRetryPolicy?: Schedule.Schedule<void>;
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Retry policy when a backing failure occurs.
|
|
@@ -50,16 +50,58 @@ export interface DistributedSemaphoreConfig {
|
|
|
50
50
|
* - Trying to acquire permits
|
|
51
51
|
* - Refreshing the TTL
|
|
52
52
|
* - Releasing permits
|
|
53
|
+
* @default Schedule.recurs(3)
|
|
53
54
|
*/
|
|
54
55
|
readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const DEFAULT_LIMIT = 1;
|
|
58
59
|
const DEFAULT_TTL = Duration.seconds(30);
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
).pipe(Schedule.asVoid);
|
|
60
|
+
const DEFAULT_ACQUIRE_RETRY_POLICY = Schedule.spaced(Duration.millis(100)).pipe(
|
|
61
|
+
Schedule.asVoid
|
|
62
|
+
);
|
|
63
|
+
const DEFAULT_FAILURE_RETRY_POLICY = Schedule.recurs(3).pipe(Schedule.asVoid);
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Acquire Options
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options for acquire operations (take, tryTake, withPermits, etc.)
|
|
71
|
+
*/
|
|
72
|
+
export interface AcquireOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Unique identifier for this permit holder.
|
|
75
|
+
*
|
|
76
|
+
* By default, a random UUID is generated per-acquire. Override this if you need:
|
|
77
|
+
* - Predictable identifiers for debugging/observability
|
|
78
|
+
* - Cross-process lock handoff (acquire in one process, release in another)
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ **Warning**: Must be unique across concurrent holders, otherwise locks with the same
|
|
81
|
+
* identifier may be treated as the same holder.
|
|
82
|
+
*
|
|
83
|
+
* @default crypto.randomUUID()
|
|
84
|
+
*/
|
|
85
|
+
readonly identifier?: string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* If true, assumes the permits were already acquired externally with the given identifier.
|
|
89
|
+
* Instead of acquiring, uses refresh to verify ownership.
|
|
90
|
+
*
|
|
91
|
+
* **Requires `identifier` to be provided.**
|
|
92
|
+
*
|
|
93
|
+
* This is useful for cross-process lock handoff:
|
|
94
|
+
* 1. Process A acquires permits with a known identifier
|
|
95
|
+
* 2. Process A passes the identifier to Process B (via message queue, etc.)
|
|
96
|
+
* 3. Process B calls take/withPermits with `{ identifier, acquiredExternally: true }`
|
|
97
|
+
* 4. Process B now owns the permits (refreshing and releasing)
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ **Unsafe**: If the identifier is wrong or the lock expired, this will fail immediately.
|
|
100
|
+
*
|
|
101
|
+
* @default false
|
|
102
|
+
*/
|
|
103
|
+
readonly acquiredExternally?: boolean;
|
|
104
|
+
}
|
|
63
105
|
|
|
64
106
|
// =============================================================================
|
|
65
107
|
// Distributed Semaphore Interface
|
|
@@ -107,7 +149,8 @@ export interface DistributedSemaphore {
|
|
|
107
149
|
* The permit TTL is refreshed automatically while the effect runs.
|
|
108
150
|
*/
|
|
109
151
|
readonly withPermits: (
|
|
110
|
-
permits: number
|
|
152
|
+
permits: number,
|
|
153
|
+
options?: AcquireOptions
|
|
111
154
|
) => <A, E, R>(
|
|
112
155
|
effect: Effect.Effect<A, E, R>
|
|
113
156
|
) => Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R>;
|
|
@@ -118,7 +161,8 @@ export interface DistributedSemaphore {
|
|
|
118
161
|
* None if permits were not available.
|
|
119
162
|
*/
|
|
120
163
|
readonly withPermitsIfAvailable: (
|
|
121
|
-
permits: number
|
|
164
|
+
permits: number,
|
|
165
|
+
options?: AcquireOptions
|
|
122
166
|
) => <A, E, R>(
|
|
123
167
|
effect: Effect.Effect<A, E, R>
|
|
124
168
|
) => Effect.Effect<
|
|
@@ -136,7 +180,8 @@ export interface DistributedSemaphore {
|
|
|
136
180
|
* When the scope closes, the fiber is interrupted and permits are released.
|
|
137
181
|
*/
|
|
138
182
|
readonly take: (
|
|
139
|
-
permits: number
|
|
183
|
+
permits: number,
|
|
184
|
+
options?: AcquireOptions
|
|
140
185
|
) => Effect.Effect<
|
|
141
186
|
Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
|
|
142
187
|
LockLostError | SemaphoreBackingError,
|
|
@@ -152,7 +197,8 @@ export interface DistributedSemaphore {
|
|
|
152
197
|
* When the scope closes, the fiber is interrupted and permits are released.
|
|
153
198
|
*/
|
|
154
199
|
readonly tryTake: (
|
|
155
|
-
permits: number
|
|
200
|
+
permits: number,
|
|
201
|
+
options?: AcquireOptions
|
|
156
202
|
) => Effect.Effect<
|
|
157
203
|
Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
|
|
158
204
|
LockLostError | SemaphoreBackingError,
|
|
@@ -174,7 +220,7 @@ type FullyResolvedConfig = {
|
|
|
174
220
|
limit: number;
|
|
175
221
|
ttl: Duration.Duration;
|
|
176
222
|
refreshInterval: Duration.Duration;
|
|
177
|
-
|
|
223
|
+
acquireRetryPolicy: Schedule.Schedule<void>;
|
|
178
224
|
backingFailureRetryPolicy: Schedule.Schedule<void>;
|
|
179
225
|
};
|
|
180
226
|
|
|
@@ -186,9 +232,9 @@ function fullyResolveConfig(
|
|
|
186
232
|
const refreshInterval = config.refreshInterval
|
|
187
233
|
? Duration.decode(config.refreshInterval)
|
|
188
234
|
: Duration.millis(Duration.toMillis(ttl) / 3);
|
|
189
|
-
const
|
|
190
|
-
?
|
|
191
|
-
:
|
|
235
|
+
const acquireRetryPolicy = config.acquireRetryPolicy
|
|
236
|
+
? config.acquireRetryPolicy
|
|
237
|
+
: DEFAULT_ACQUIRE_RETRY_POLICY;
|
|
192
238
|
const backingFailureRetryPolicy = config.backingFailureRetryPolicy
|
|
193
239
|
? config.backingFailureRetryPolicy
|
|
194
240
|
: DEFAULT_FAILURE_RETRY_POLICY;
|
|
@@ -197,7 +243,7 @@ function fullyResolveConfig(
|
|
|
197
243
|
limit,
|
|
198
244
|
ttl,
|
|
199
245
|
refreshInterval,
|
|
200
|
-
|
|
246
|
+
acquireRetryPolicy,
|
|
201
247
|
backingFailureRetryPolicy,
|
|
202
248
|
};
|
|
203
249
|
}
|
|
@@ -228,15 +274,12 @@ export const make = (
|
|
|
228
274
|
Effect.gen(function* () {
|
|
229
275
|
const backing = yield* DistributedSemaphoreBacking;
|
|
230
276
|
|
|
231
|
-
// Generate unique holder ID for this instance
|
|
232
|
-
const holderId = crypto.randomUUID();
|
|
233
|
-
|
|
234
277
|
// Resolve config with defaults
|
|
235
278
|
const {
|
|
236
279
|
limit,
|
|
237
280
|
ttl,
|
|
238
281
|
refreshInterval,
|
|
239
|
-
|
|
282
|
+
acquireRetryPolicy,
|
|
240
283
|
backingFailureRetryPolicy,
|
|
241
284
|
} = fullyResolveConfig(config);
|
|
242
285
|
|
|
@@ -253,12 +296,13 @@ export const make = (
|
|
|
253
296
|
// Keep the permits alive by refreshing TTL periodically.
|
|
254
297
|
// This effect runs forever until interrupted (when scope closes).
|
|
255
298
|
const keepAlive = (
|
|
299
|
+
identifier: string,
|
|
256
300
|
permits: number
|
|
257
301
|
): Effect.Effect<never, SemaphoreBackingError | LockLostError, never> =>
|
|
258
302
|
Effect.repeat(
|
|
259
303
|
Effect.gen(function* () {
|
|
260
304
|
const refreshed = yield* backing
|
|
261
|
-
.refresh(key,
|
|
305
|
+
.refresh(key, identifier, ttl, limit, permits)
|
|
262
306
|
.pipe(withBackingErrorRetry);
|
|
263
307
|
|
|
264
308
|
if (!refreshed) {
|
|
@@ -276,27 +320,40 @@ export const make = (
|
|
|
276
320
|
|
|
277
321
|
// Try to acquire permits immediately, returns Option
|
|
278
322
|
const tryTake = (
|
|
279
|
-
permits: number
|
|
323
|
+
permits: number,
|
|
324
|
+
options?: AcquireOptions
|
|
280
325
|
): Effect.Effect<
|
|
281
326
|
Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
|
|
282
327
|
SemaphoreBackingError,
|
|
283
328
|
Scope.Scope
|
|
284
329
|
> =>
|
|
285
330
|
Effect.gen(function* () {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
331
|
+
// Generate identifier per-acquire if not provided
|
|
332
|
+
const identifier = options?.identifier ?? crypto.randomUUID();
|
|
333
|
+
const acquiredExternally = options?.acquiredExternally ?? false;
|
|
334
|
+
|
|
335
|
+
// If acquiredExternally, use refresh to verify ownership instead of acquire
|
|
336
|
+
const acquired = acquiredExternally
|
|
337
|
+
? yield* backing
|
|
338
|
+
.refresh(key, identifier, ttl, limit, permits)
|
|
339
|
+
.pipe(withBackingErrorRetry)
|
|
340
|
+
: yield* backing
|
|
341
|
+
.tryAcquire(key, identifier, ttl, limit, permits)
|
|
342
|
+
.pipe(withBackingErrorRetry);
|
|
343
|
+
|
|
289
344
|
if (!acquired) {
|
|
290
345
|
return Option.none();
|
|
291
346
|
}
|
|
292
347
|
|
|
293
348
|
// Start keepalive fiber, tied to this scope
|
|
294
|
-
const keepAliveFiber = yield* Effect.forkScoped(
|
|
349
|
+
const keepAliveFiber = yield* Effect.forkScoped(
|
|
350
|
+
keepAlive(identifier, permits)
|
|
351
|
+
);
|
|
295
352
|
|
|
296
353
|
// Add finalizer to release permits when scope closes
|
|
297
354
|
yield* Effect.addFinalizer(() =>
|
|
298
355
|
backing
|
|
299
|
-
.release(key,
|
|
356
|
+
.release(key, identifier, permits)
|
|
300
357
|
.pipe(withBackingErrorRetry, Effect.ignore)
|
|
301
358
|
);
|
|
302
359
|
|
|
@@ -305,14 +362,21 @@ export const make = (
|
|
|
305
362
|
|
|
306
363
|
// Acquire permits with retry, returns fiber when acquired
|
|
307
364
|
const take = (
|
|
308
|
-
permits: number
|
|
365
|
+
permits: number,
|
|
366
|
+
options?: AcquireOptions
|
|
309
367
|
): Effect.Effect<
|
|
310
368
|
Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
|
|
311
369
|
SemaphoreBackingError,
|
|
312
370
|
Scope.Scope
|
|
313
371
|
> =>
|
|
314
372
|
Effect.gen(function* () {
|
|
315
|
-
|
|
373
|
+
// Generate identifier once for all retry attempts (outside the retry loop)
|
|
374
|
+
const identifier = options?.identifier ?? crypto.randomUUID();
|
|
375
|
+
const resolvedOptions: AcquireOptions = {
|
|
376
|
+
identifier,
|
|
377
|
+
acquiredExternally: options?.acquiredExternally,
|
|
378
|
+
};
|
|
379
|
+
const maybeAcquired = yield* tryTake(permits, resolvedOptions);
|
|
316
380
|
if (Option.isNone(maybeAcquired)) {
|
|
317
381
|
return yield* new NotYetAcquiredError();
|
|
318
382
|
}
|
|
@@ -320,7 +384,7 @@ export const make = (
|
|
|
320
384
|
}).pipe(
|
|
321
385
|
Effect.retry({
|
|
322
386
|
while: (e) => e._tag === "NotYetAcquiredError",
|
|
323
|
-
schedule:
|
|
387
|
+
schedule: acquireRetryPolicy,
|
|
324
388
|
}),
|
|
325
389
|
Effect.catchTag("NotYetAcquiredError", () =>
|
|
326
390
|
Effect.dieMessage(
|
|
@@ -332,13 +396,13 @@ export const make = (
|
|
|
332
396
|
|
|
333
397
|
// Convenience: acquire permits, run effect, release when done
|
|
334
398
|
const withPermits =
|
|
335
|
-
(permits: number) =>
|
|
399
|
+
(permits: number, options?: AcquireOptions) =>
|
|
336
400
|
<A, E, R>(
|
|
337
401
|
effect: Effect.Effect<A, E, R>
|
|
338
402
|
): Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R> =>
|
|
339
403
|
Effect.scoped(
|
|
340
404
|
Effect.gen(function* () {
|
|
341
|
-
const keepAliveFiber = yield* take(permits);
|
|
405
|
+
const keepAliveFiber = yield* take(permits, options);
|
|
342
406
|
|
|
343
407
|
return yield* Effect.raceFirst(effect, Fiber.join(keepAliveFiber));
|
|
344
408
|
})
|
|
@@ -346,7 +410,7 @@ export const make = (
|
|
|
346
410
|
|
|
347
411
|
// Convenience: try to acquire permits, run effect if successful
|
|
348
412
|
const withPermitsIfAvailable =
|
|
349
|
-
(permits: number) =>
|
|
413
|
+
(permits: number, options?: AcquireOptions) =>
|
|
350
414
|
<A, E, R>(
|
|
351
415
|
effect: Effect.Effect<A, E, R>
|
|
352
416
|
): Effect.Effect<
|
|
@@ -356,7 +420,7 @@ export const make = (
|
|
|
356
420
|
> =>
|
|
357
421
|
Effect.scoped(
|
|
358
422
|
Effect.gen(function* () {
|
|
359
|
-
const maybeAcquired = yield* tryTake(permits);
|
|
423
|
+
const maybeAcquired = yield* tryTake(permits, options);
|
|
360
424
|
if (Option.isNone(maybeAcquired)) {
|
|
361
425
|
return Option.none();
|
|
362
426
|
}
|
package/src/RedisBacking.ts
CHANGED
|
@@ -155,7 +155,7 @@ return redis.call('zcard', key)
|
|
|
155
155
|
*/
|
|
156
156
|
export const layer = (
|
|
157
157
|
redis: Redis,
|
|
158
|
-
keyPrefix = "
|
|
158
|
+
keyPrefix = "semaphore:"
|
|
159
159
|
): Layer.Layer<DistributedSemaphoreBacking> => {
|
|
160
160
|
const prefixKey = (key: string) => `${keyPrefix}${key}`;
|
|
161
161
|
|