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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-distributed-lock",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A distributed semaphore library for Effect with pluggable backends",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,9 +40,9 @@ export interface DistributedSemaphoreConfig {
40
40
 
41
41
  /**
42
42
  * How often to poll when waiting to acquire permits.
43
- * @default 100ms
43
+ * @default Schedule.spaced(Duration.millis(100))
44
44
  */
45
- readonly acquireRetryInterval?: Duration.DurationInput;
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 DEFAULT_ACQUIRE_RETRY_INTERVAL = Duration.millis(100);
60
- const DEFAULT_FAILURE_RETRY_POLICY = Schedule.spaced(
61
- DEFAULT_ACQUIRE_RETRY_INTERVAL
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
- acquireRetryInterval: Duration.Duration;
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 acquireRetryInterval = config.acquireRetryInterval
190
- ? Duration.decode(config.acquireRetryInterval)
191
- : DEFAULT_ACQUIRE_RETRY_INTERVAL;
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
- acquireRetryInterval,
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
- acquireRetryInterval,
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, holderId, ttl, limit, permits)
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
- const acquired = yield* backing
287
- .tryAcquire(key, holderId, ttl, limit, permits)
288
- .pipe(withBackingErrorRetry);
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(keepAlive(permits));
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, holderId, permits)
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
- const maybeAcquired = yield* tryTake(permits);
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: Schedule.spaced(acquireRetryInterval),
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
  }
@@ -155,7 +155,7 @@ return redis.call('zcard', key)
155
155
  */
156
156
  export const layer = (
157
157
  redis: Redis,
158
- keyPrefix = "dsem:"
158
+ keyPrefix = "semaphore:"
159
159
  ): Layer.Layer<DistributedSemaphoreBacking> => {
160
160
  const prefixKey = (key: string) => `${keyPrefix}${key}`;
161
161