effect-distributed-lock 0.0.10 → 0.0.11

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
@@ -111,7 +111,11 @@ yield* Effect.scoped(
111
111
  );
112
112
  ```
113
113
 
114
- 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
+ Both `take` and `tryTake` return the keepalive fiber that refreshes the permit TTL.
115
+
116
+ ⚠️ **CRITICAL**: Errors from the keepalive fiber (losing permits or backing store failure) mean **the lock is effectively lost**. You **must** join this fiber at some point in your program to detect these failures. If the keepalive fiber errors and you don't join it, your program will continue running without holding the lock, potentially leading to race conditions or data corruption.
117
+
118
+ **It is highly recommended to use `withPermits` or `withPermitsIfAvailable` instead**, which automatically manage the keepalive fiber lifecycle and propagate errors for you. Only use `take`/`tryTake` if you need explicit scope control and understand the responsibility of managing the keepalive fiber.
115
119
 
116
120
  ### Acquire Options
117
121
 
@@ -0,0 +1,54 @@
1
+ import { Context, Duration, Effect, Stream } from "effect";
2
+ declare const SemaphoreBackingError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
3
+ readonly _tag: "SemaphoreBackingError";
4
+ } & Readonly<A>;
5
+ /**
6
+ * Error from the backing store (Redis, etc.)
7
+ */
8
+ export declare class SemaphoreBackingError extends SemaphoreBackingError_base<{
9
+ readonly operation: string;
10
+ readonly cause: unknown;
11
+ }> {
12
+ get message(): string;
13
+ }
14
+ /**
15
+ * Low-level backing store interface for distributed semaphore operations.
16
+ */
17
+ export interface DistributedSemaphoreBacking {
18
+ /**
19
+ * Try to acquire `permits` from a semaphore with the given `limit`.
20
+ *
21
+ * @returns `true` if acquired, `false` if not enough permits available.
22
+ */
23
+ readonly tryAcquire: (key: string, holderId: string, ttl: Duration.Duration, limit: number, permits: number) => Effect.Effect<boolean, SemaphoreBackingError>;
24
+ /**
25
+ * Release `permits` held by the given holder.
26
+ *
27
+ * @returns The number of permits actually released.
28
+ */
29
+ readonly release: (key: string, holderId: string, permits: number) => Effect.Effect<number, SemaphoreBackingError>;
30
+ /**
31
+ * Refresh the TTL on permits held by this holder.
32
+ *
33
+ * @returns `true` if refreshed, `false` if permits were lost (e.g., expired).
34
+ */
35
+ readonly refresh: (key: string, holderId: string, ttl: Duration.Duration, limit: number, permits: number) => Effect.Effect<boolean, SemaphoreBackingError>;
36
+ /**
37
+ * Get the number of permits currently held (in use).
38
+ */
39
+ readonly getCount: (key: string, ttl: Duration.Duration) => Effect.Effect<number, SemaphoreBackingError>;
40
+ /**
41
+ * Optional: Stream of notifications when permits MAY be available.
42
+ *
43
+ * If provided, the semaphore layer uses this in addition to polling. The stream emits a signal whenever permits are released.
44
+ *
45
+ * Notes:
46
+ * - Multiple waiters may race for permits after a notification
47
+ * - The semaphore still calls `tryAcquire` after each notification
48
+ * - Implementations should handle reconnection internally (hence why the stream does not have an error type)
49
+ */
50
+ readonly onPermitsReleased?: (key: string) => Stream.Stream<void>;
51
+ }
52
+ export declare const DistributedSemaphoreBacking: Context.Tag<DistributedSemaphoreBacking, DistributedSemaphoreBacking>;
53
+ export {};
54
+ //# sourceMappingURL=Backing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Backing.d.ts","sourceRoot":"","sources":["../src/Backing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAQ,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;;;AAMjE;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,2BAEzC;IACA,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB,CAAC;IACA,IAAI,OAAO,WAEV;CACF;AAMD;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,CACnB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,QAAQ,CAAC,QAAQ,EACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,KACZ,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,qBAAqB,CAAC,CAAC;IAEnD;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,CAChB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,KACZ,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAElD;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,CAChB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,QAAQ,CAAC,QAAQ,EACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,KACZ,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,qBAAqB,CAAC,CAAC;IAEnD;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,CACjB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,QAAQ,CAAC,QAAQ,KACnB,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAElD;;;;;;;;;OASG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACnE;AAED,eAAO,MAAM,2BAA2B,uEAGrC,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { Context, Data, Duration, Effect, Stream } from "effect";
2
+ // =============================================================================
3
+ // Errors
4
+ // =============================================================================
5
+ /**
6
+ * Error from the backing store (Redis, etc.)
7
+ */
8
+ export class SemaphoreBackingError extends Data.TaggedError("SemaphoreBackingError") {
9
+ get message() {
10
+ return `Backing store error during "${this.operation}": ${this.cause}`;
11
+ }
12
+ }
13
+ export const DistributedSemaphoreBacking = Context.GenericTag("@effect-distributed-lock/DistributedSemaphoreBacking");
14
+ //# sourceMappingURL=Backing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Backing.js","sourceRoot":"","sources":["../src/Backing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEjE,gFAAgF;AAChF,SAAS;AACT,gFAAgF;AAEhF;;GAEG;AACH,MAAM,OAAO,qBAAsB,SAAQ,IAAI,CAAC,WAAW,CACzD,uBAAuB,CAIvB;IACA,IAAI,OAAO;QACT,OAAO,+BAA+B,IAAI,CAAC,SAAS,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACzE,CAAC;CACF;AAoED,MAAM,CAAC,MAAM,2BAA2B,GACtC,OAAO,CAAC,UAAU,CAChB,sDAAsD,CACvD,CAAC"}
@@ -0,0 +1,162 @@
1
+ import { Duration, Effect, Fiber, Option, Schedule, Scope } from "effect";
2
+ import { DistributedSemaphoreBacking, SemaphoreBackingError } from "./Backing.js";
3
+ import { LockLostError, LockNotAcquiredError } from "./Errors.js";
4
+ export interface DistributedSemaphoreConfig {
5
+ /**
6
+ * Maximum number of permits available.
7
+ * This is the total capacity of the semaphore.
8
+ * @default 1 (mutex behavior)
9
+ */
10
+ readonly limit?: number;
11
+ /**
12
+ * TTL for held permits. If the holder crashes, permits auto-release after this.
13
+ * @default 30 seconds
14
+ */
15
+ readonly ttl?: Duration.DurationInput;
16
+ /**
17
+ * How often to refresh the TTL while holding permits.
18
+ * Should be less than TTL to avoid losing the permits.
19
+ * @default 1/3 of TTL
20
+ */
21
+ readonly refreshInterval?: Duration.DurationInput;
22
+ /**
23
+ * How often to poll when waiting to acquire permits.
24
+ * @default Schedule.spaced(Duration.millis(100))
25
+ */
26
+ readonly acquireRetryPolicy?: Schedule.Schedule<unknown>;
27
+ /**
28
+ * Retry policy when a backing failure occurs.
29
+ * This could happen when:
30
+ * - Trying to acquire permits
31
+ * - Refreshing the TTL
32
+ * - Releasing permits
33
+ * @default Schedule.recurs(3)
34
+ */
35
+ readonly backingFailureRetryPolicy?: Schedule.Schedule<unknown>;
36
+ }
37
+ /**
38
+ * Options for acquire operations (take, tryTake, withPermits, etc.)
39
+ */
40
+ export interface AcquireOptions {
41
+ /**
42
+ * Unique identifier for this permit holder.
43
+ *
44
+ * By default, a random UUID is generated per-acquire. Override this if you need:
45
+ * - Predictable identifiers for debugging/observability
46
+ * - Cross-process lock handoff (acquire in one process, release in another)
47
+ *
48
+ * ⚠️ **Warning**: Must be unique across concurrent holders, otherwise locks with the same
49
+ * identifier may be treated as the same holder.
50
+ *
51
+ * @default crypto.randomUUID()
52
+ */
53
+ readonly identifier?: string;
54
+ /**
55
+ * If true, assumes the permits were already acquired externally with the given identifier.
56
+ * Instead of acquiring, uses refresh to verify ownership.
57
+ *
58
+ * **Requires `identifier` to be provided.**
59
+ *
60
+ * This is useful for cross-process lock handoff:
61
+ * 1. Process A acquires permits with a known identifier
62
+ * 2. Process A passes the identifier to Process B (via message queue, etc.)
63
+ * 3. Process B calls take/withPermits with `{ identifier, acquiredExternally: true }`
64
+ * 4. Process B now owns the permits (refreshing and releasing)
65
+ *
66
+ * ⚠️ **Unsafe**: If the identifier is wrong or the lock expired, this will fail immediately.
67
+ *
68
+ * @default false
69
+ */
70
+ readonly acquiredExternally?: boolean;
71
+ }
72
+ /**
73
+ * A distributed semaphore that can be used across multiple processes/services.
74
+ *
75
+ * Similar to Effect's built-in Semaphore, but distributed across processes
76
+ * using a backing store like Redis.
77
+ *
78
+ * A semaphore manages a pool of permits. Tasks can acquire one or more permits,
79
+ * and must release them when done. If not enough permits are available, tasks
80
+ * wait until permits are released.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * // Create a semaphore that allows 5 concurrent operations
85
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
86
+ *
87
+ * // Acquire 2 permits, run effect, then release
88
+ * yield* sem.withPermits(2)(myEffect);
89
+ *
90
+ * // Create a mutex (limit=1) for exclusive access
91
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
92
+ * yield* mutex.withPermits(1)(criticalSection);
93
+ * ```
94
+ */
95
+ export interface DistributedSemaphore {
96
+ /**
97
+ * The key identifying this semaphore in the backing store.
98
+ */
99
+ readonly key: string;
100
+ /**
101
+ * The maximum number of permits available.
102
+ */
103
+ readonly limit: number;
104
+ /**
105
+ * Run an effect with the specified number of permits.
106
+ * Acquires the permits, runs the effect, then releases.
107
+ * If not enough permits are available, waits until they are.
108
+ *
109
+ * The permit TTL is refreshed automatically while the effect runs.
110
+ */
111
+ readonly withPermits: (permits: number, options?: AcquireOptions) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockLostError | SemaphoreBackingError | LockNotAcquiredError, R>;
112
+ /**
113
+ * Run an effect only if the specified permits are immediately available.
114
+ * Returns Some(result) if permits were acquired and effect ran,
115
+ * None if permits were not available.
116
+ */
117
+ readonly withPermitsIfAvailable: (permits: number, options?: AcquireOptions) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E | LockLostError | SemaphoreBackingError, R>;
118
+ /**
119
+ * Acquire the specified number of permits, waiting if necessary.
120
+ * The permits are held until the scope is closed.
121
+ * The permit TTL is refreshed automatically while held.
122
+ *
123
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
124
+ * When the scope closes, the fiber is interrupted and permits are released.
125
+ */
126
+ readonly take: (permits: number, options?: AcquireOptions) => Effect.Effect<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>, LockLostError | SemaphoreBackingError | LockNotAcquiredError, Scope.Scope>;
127
+ /**
128
+ * Try to acquire permits immediately without waiting.
129
+ * Returns Some(fiber) if acquired (permits held until scope closes),
130
+ * None if permits were not available.
131
+ *
132
+ * @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
133
+ * When the scope closes, the fiber is interrupted and permits are released.
134
+ */
135
+ readonly tryTake: (permits: number, options?: AcquireOptions) => Effect.Effect<Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>, LockLostError | SemaphoreBackingError, Scope.Scope>;
136
+ /**
137
+ * Get the current number of active permits (for debugging/introspection).
138
+ * Note: This is eventually consistent due to TTL-based expiration.
139
+ */
140
+ readonly currentCount: Effect.Effect<number, SemaphoreBackingError>;
141
+ }
142
+ /**
143
+ * Create a distributed semaphore for the given key.
144
+ *
145
+ * @param key - Unique identifier for this semaphore in the backing store
146
+ * @param config - Configuration options
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * // Create a semaphore that allows 5 concurrent operations
151
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
152
+ *
153
+ * // Use withPermits to run an effect with acquired permits
154
+ * yield* sem.withPermits(2)(Effect.log("I have 2 permits"));
155
+ *
156
+ * // Use withPermitsIfAvailable to try without waiting
157
+ * const result = yield* sem.withPermitsIfAvailable(1)(Effect.succeed(42));
158
+ * // result: Option<number>
159
+ * ```
160
+ */
161
+ export declare const make: (key: string, config?: DistributedSemaphoreConfig) => Effect.Effect<DistributedSemaphore, never, DistributedSemaphoreBacking>;
162
+ //# sourceMappingURL=DistributedSemaphore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DistributedSemaphore.d.ts","sourceRoot":"","sources":["../src/DistributedSemaphore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,163 @@
1
+ import { Deferred, Duration, Effect, Fiber, Function, Option, Schedule, Scope, Stream, } from "effect";
2
+ import { DistributedSemaphoreBacking, SemaphoreBackingError, } from "./Backing.js";
3
+ import { LockLostError, LockNotAcquiredError } from "./Errors.js";
4
+ const DEFAULT_LIMIT = 1;
5
+ const DEFAULT_TTL = Duration.seconds(30);
6
+ const DEFAULT_ACQUIRE_RETRY_POLICY = Schedule.spaced(Duration.millis(100));
7
+ const DEFAULT_FAILURE_RETRY_POLICY = Schedule.recurs(3);
8
+ function fullyResolveConfig(config) {
9
+ const limit = config.limit ?? DEFAULT_LIMIT;
10
+ const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
11
+ const refreshInterval = config.refreshInterval
12
+ ? Duration.decode(config.refreshInterval)
13
+ : Duration.millis(Duration.toMillis(ttl) / 3);
14
+ const acquireRetryPolicy = config.acquireRetryPolicy
15
+ ? config.acquireRetryPolicy
16
+ : DEFAULT_ACQUIRE_RETRY_POLICY;
17
+ const backingFailureRetryPolicy = config.backingFailureRetryPolicy
18
+ ? config.backingFailureRetryPolicy
19
+ : DEFAULT_FAILURE_RETRY_POLICY;
20
+ return {
21
+ limit,
22
+ ttl,
23
+ refreshInterval,
24
+ acquireRetryPolicy,
25
+ backingFailureRetryPolicy,
26
+ };
27
+ }
28
+ /**
29
+ * Create a distributed semaphore for the given key.
30
+ *
31
+ * @param key - Unique identifier for this semaphore in the backing store
32
+ * @param config - Configuration options
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // Create a semaphore that allows 5 concurrent operations
37
+ * const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
38
+ *
39
+ * // Use withPermits to run an effect with acquired permits
40
+ * yield* sem.withPermits(2)(Effect.log("I have 2 permits"));
41
+ *
42
+ * // Use withPermitsIfAvailable to try without waiting
43
+ * const result = yield* sem.withPermitsIfAvailable(1)(Effect.succeed(42));
44
+ * // result: Option<number>
45
+ * ```
46
+ */
47
+ export const make = (key, config = {}) => Effect.gen(function* () {
48
+ const backing = yield* DistributedSemaphoreBacking;
49
+ // Resolve config with defaults
50
+ const { limit, ttl, refreshInterval, acquireRetryPolicy, backingFailureRetryPolicy, } = fullyResolveConfig(config);
51
+ const withBackingErrorRetry = (effect) => effect.pipe(Effect.retry({
52
+ while: (e) => e._tag === "SemaphoreBackingError",
53
+ schedule: backingFailureRetryPolicy,
54
+ }));
55
+ // Keep the permits alive by refreshing TTL periodically.
56
+ // This effect runs forever until interrupted (when scope closes).
57
+ const keepAlive = (identifier, permits) => Effect.repeat(Effect.gen(function* () {
58
+ const refreshed = yield* backing
59
+ .refresh(key, identifier, ttl, limit, permits)
60
+ .pipe(withBackingErrorRetry);
61
+ if (!refreshed) {
62
+ return yield* new LockLostError({ key });
63
+ }
64
+ }), Schedule.spaced(refreshInterval)).pipe(Effect.andThen(Effect.dieMessage("Invariant violated: `keepAlive` should never return a value")));
65
+ // Try to acquire permits immediately, returns Option
66
+ const tryTake = (permits, options) => Effect.gen(function* () {
67
+ // Generate identifier per-acquire if not provided
68
+ const identifier = options?.identifier ?? crypto.randomUUID();
69
+ const acquiredExternally = options?.acquiredExternally ?? false;
70
+ // If acquiredExternally, use refresh to verify ownership instead of acquire
71
+ const acquired = acquiredExternally
72
+ ? yield* backing
73
+ .refresh(key, identifier, ttl, limit, permits)
74
+ .pipe(withBackingErrorRetry)
75
+ : yield* backing
76
+ .tryAcquire(key, identifier, ttl, limit, permits)
77
+ .pipe(withBackingErrorRetry);
78
+ if (!acquired) {
79
+ return Option.none();
80
+ }
81
+ // Start keepalive fiber, tied to this scope
82
+ const keepAliveFiber = yield* Effect.forkScoped(keepAlive(identifier, permits));
83
+ // Add finalizer to release permits when scope closes
84
+ yield* Effect.addFinalizer(() => backing
85
+ .release(key, identifier, permits)
86
+ .pipe(withBackingErrorRetry, Effect.ignore));
87
+ return Option.some(keepAliveFiber);
88
+ });
89
+ // Acquire permits with retry, returns fiber when acquired
90
+ const take = (permits, options) => Effect.gen(function* () {
91
+ // Generate identifier once for all retry attempts (outside the retry loop)
92
+ const identifier = options?.identifier ?? crypto.randomUUID();
93
+ const resolvedOptions = {
94
+ identifier,
95
+ acquiredExternally: options?.acquiredExternally,
96
+ };
97
+ // We use a semaphore to ensure that only one acquire attempt is made at a time.
98
+ // With `withPermitsIfAvailable`, if both the poll-based and push-based attempts "trigger" at the same time,
99
+ // one will succeed and the other will simple be a no-op.
100
+ const acquireSemaphore = yield* Effect.makeSemaphore(1);
101
+ const pushBasedAcquireEnabled = backing.onPermitsReleased
102
+ ? true
103
+ : false;
104
+ const pollBasedAcquire = Effect.gen(function* () {
105
+ const maybeAcquired = yield* tryTake(permits, resolvedOptions).pipe(
106
+ // only apply the semaphore if push-based acquire is supported
107
+ pushBasedAcquireEnabled
108
+ ? Function.compose(acquireSemaphore.withPermitsIfAvailable(1), Effect.map(Option.flatten))
109
+ : Function.identity);
110
+ if (Option.isNone(maybeAcquired)) {
111
+ return yield* new LockNotAcquiredError({ key });
112
+ }
113
+ return maybeAcquired.value;
114
+ }).pipe(Effect.retry({
115
+ while: (e) => e._tag === "LockNotAcquiredError" && !resolvedOptions.acquiredExternally,
116
+ schedule: acquireRetryPolicy,
117
+ }));
118
+ if (!pushBasedAcquireEnabled) {
119
+ return yield* pollBasedAcquire;
120
+ }
121
+ // Push-based acquire: run both poll-based and push-based acquire in parallel, and return the first one to complete
122
+ const pushBasedAcquire = Effect.gen(function* () {
123
+ if (!backing.onPermitsReleased) {
124
+ // SAFETY: We know that onPermitsReleased is provided because we checked it above
125
+ return yield* Effect.dieMessage("Invariant violated: `onPermitsReleased` is not provided when it was expected");
126
+ }
127
+ return yield* backing.onPermitsReleased(key).pipe(Stream.runFoldWhileEffect(Option.none(), Option.isNone, // keep folding while we haven't acquired
128
+ () => tryTake(permits, resolvedOptions).pipe(acquireSemaphore.withPermitsIfAvailable(1), Effect.map(Option.flatten))), Effect.flatMap(Option.match({
129
+ onSome: Effect.succeed,
130
+ onNone: () => Effect.dieMessage("Invariant violated: the stream should never return `None`")
131
+ })));
132
+ });
133
+ // first to succeed (acquire permits) wins
134
+ return yield* Effect.race(pollBasedAcquire, pushBasedAcquire);
135
+ });
136
+ // Convenience: acquire permits, run effect, release when done
137
+ const withPermits = (permits, options) => (effect) => Effect.scoped(Effect.gen(function* () {
138
+ const keepAliveFiber = yield* take(permits, options);
139
+ return yield* Effect.raceFirst(effect, Fiber.join(keepAliveFiber));
140
+ }));
141
+ // Convenience: try to acquire permits, run effect if successful
142
+ const withPermitsIfAvailable = (permits, options) => (effect) => Effect.scoped(Effect.gen(function* () {
143
+ const maybeAcquired = yield* tryTake(permits, options);
144
+ if (Option.isNone(maybeAcquired)) {
145
+ return Option.none();
146
+ }
147
+ const keepAliveFiber = maybeAcquired.value;
148
+ return yield* Effect.raceFirst(effect.pipe(Effect.asSome), Fiber.join(keepAliveFiber));
149
+ }));
150
+ const currentCount = backing
151
+ .getCount(key, ttl)
152
+ .pipe(withBackingErrorRetry);
153
+ return {
154
+ key,
155
+ limit,
156
+ withPermits,
157
+ withPermitsIfAvailable,
158
+ take,
159
+ tryTake,
160
+ currentCount,
161
+ };
162
+ });
163
+ //# sourceMappingURL=DistributedSemaphore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DistributedSemaphore.js","sourceRoot":"","sources":["../src/DistributedSemaphore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ declare const LockLostError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "LockLostError";
3
+ } & Readonly<A>;
4
+ /**
5
+ * The permits were lost (TTL expired while we thought we held them)
6
+ */
7
+ export declare class LockLostError extends LockLostError_base<{
8
+ readonly key: string;
9
+ }> {
10
+ get message(): string;
11
+ }
12
+ declare const LockNotAcquiredError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
13
+ readonly _tag: "LockNotAcquiredError";
14
+ } & Readonly<A>;
15
+ /**
16
+ * The lock failed to be acquired. This occurs when the lock is not aquired within the provided schedule.
17
+ */
18
+ export declare class LockNotAcquiredError extends LockNotAcquiredError_base<{
19
+ readonly key: string;
20
+ }> {
21
+ get message(): string;
22
+ }
23
+ export {};
24
+ //# sourceMappingURL=Errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Errors.d.ts","sourceRoot":"","sources":["../src/Errors.ts"],"names":[],"mappings":";;;AAEA;;GAEG;AACH,qBAAa,aAAc,SAAQ,mBAAkC;IACnE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB,CAAC;IACA,IAAI,OAAO,WAEV;CACF;;;;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,0BAExC;IACA,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB,CAAC;IACA,IAAI,OAAO,WAEV;CACF"}
package/dist/Errors.js ADDED
@@ -0,0 +1,18 @@
1
+ import { Data } from "effect";
2
+ /**
3
+ * The permits were lost (TTL expired while we thought we held them)
4
+ */
5
+ export class LockLostError extends Data.TaggedError("LockLostError") {
6
+ get message() {
7
+ return `Permits for "${this.key}" were lost (TTL expired or taken by another holder)`;
8
+ }
9
+ }
10
+ /**
11
+ * The lock failed to be acquired. This occurs when the lock is not aquired within the provided schedule.
12
+ */
13
+ export class LockNotAcquiredError extends Data.TaggedError("LockNotAcquiredError") {
14
+ get message() {
15
+ return `Lock for "${this.key}" was not acquired`;
16
+ }
17
+ }
18
+ //# sourceMappingURL=Errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Errors.js","sourceRoot":"","sources":["../src/Errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B;;GAEG;AACH,MAAM,OAAO,aAAc,SAAQ,IAAI,CAAC,WAAW,CAAC,eAAe,CAEjE;IACA,IAAI,OAAO;QACT,OAAO,gBAAgB,IAAI,CAAC,GAAG,sDAAsD,CAAC;IACxF,CAAC;CACF;AAED;;GAEG;AACH,MAAM,OAAO,oBAAqB,SAAQ,IAAI,CAAC,WAAW,CACxD,sBAAsB,CAGtB;IACA,IAAI,OAAO;QACT,OAAO,aAAa,IAAI,CAAC,GAAG,oBAAoB,CAAC;IACnD,CAAC;CACF"}
@@ -0,0 +1,40 @@
1
+ import { Layer, Schedule } from "effect";
2
+ import { Redis } from "ioredis";
3
+ import { DistributedSemaphoreBacking } from "./Backing.js";
4
+ export interface RedisBackingOptions {
5
+ /**
6
+ * Prefix for all keys in Redis.
7
+ * @default "semaphore:"
8
+ */
9
+ readonly keyPrefix?: string;
10
+ /**
11
+ * Enable push-based acquisition using Redis pub/sub.
12
+ *
13
+ * When enabled, waiters subscribe to a channel and get notified immediately
14
+ * when permits are released, instead of polling. This reduces latency and
15
+ * load on Redis.
16
+ *
17
+ * Requires an additional Redis connection per waiting semaphore.
18
+ *
19
+ * @default true
20
+ */
21
+ readonly pushBasedAcquireEnabled?: boolean;
22
+ /**
23
+ * How often to retry the stream of notifications when permits are released.
24
+ * @default Schedule.forever
25
+ */
26
+ readonly pushStreamRetrySchedule?: Schedule.Schedule<unknown>;
27
+ }
28
+ /**
29
+ * Create a Redis-backed distributed semaphore backing layer.
30
+ *
31
+ * **Important:** This implementation is for single-instance Redis only.
32
+ * It does not implement the Redlock algorithm and should not be used with
33
+ * Redis Cluster or Redis Sentinel for distributed locking guarantees.
34
+ * For multi-instance Redis, consider implementing a Redlock-based backing.
35
+ *
36
+ * @param redis - An ioredis client instance (single instance, not cluster)
37
+ * @param options - Configuration options
38
+ */
39
+ export declare const layer: (redis: Redis, options?: RedisBackingOptions) => Layer.Layer<DistributedSemaphoreBacking>;
40
+ //# sourceMappingURL=RedisBacking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RedisBacking.d.ts","sourceRoot":"","sources":["../src/RedisBacking.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,EAAE,QAAQ,EAAU,MAAM,QAAQ,CAAC;AACnE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EACL,2BAA2B,EAE5B,MAAM,cAAc,CAAC;AAsJtB,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAE5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAE3C;;;OAGG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,OAAO,KAAK,EACZ,UAAS,mBAAwB,KAChC,KAAK,CAAC,KAAK,CAAC,2BAA2B,CAyJzC,CAAC"}
@@ -0,0 +1,233 @@
1
+ import { Duration, Effect, Layer, Schedule, Stream } from "effect";
2
+ import { Redis } from "ioredis";
3
+ import { DistributedSemaphoreBacking, SemaphoreBackingError, } from "./Backing.js";
4
+ /**
5
+ * Lua script for atomic semaphore acquisition.
6
+ *
7
+ * Uses a sorted set where:
8
+ * - Each member is `holderId_permitIndex` (e.g., "abc123_0", "abc123_1")
9
+ * - Score is the acquisition timestamp
10
+ * - Expired entries are removed before checking capacity
11
+ *
12
+ * Arguments:
13
+ * - KEYS[1]: the semaphore key
14
+ * - ARGV[1]: limit (max permits)
15
+ * - ARGV[2]: permits to acquire
16
+ * - ARGV[3]: holderId
17
+ * - ARGV[4]: ttlMs (lock timeout in ms)
18
+ * - ARGV[5]: now (current timestamp in ms)
19
+ *
20
+ * Returns 1 if acquired, 0 if not enough permits available.
21
+ */
22
+ const ACQUIRE_SCRIPT = `
23
+ local key = KEYS[1]
24
+ local limit = tonumber(ARGV[1])
25
+ local permits = tonumber(ARGV[2])
26
+ local holderId = ARGV[3]
27
+ local ttlMs = tonumber(ARGV[4])
28
+ local now = tonumber(ARGV[5])
29
+ local expiredTimestamp = now - ttlMs
30
+
31
+ -- Remove expired entries
32
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
33
+
34
+ -- Check if there's room for the requested permits
35
+ if (redis.call('zcard', key) + permits) <= limit then
36
+ -- Add all permits with current timestamp
37
+ local args = {}
38
+ for i = 0, permits - 1 do
39
+ table.insert(args, now)
40
+ table.insert(args, holderId .. '_' .. i)
41
+ end
42
+ redis.call('zadd', key, unpack(args))
43
+ redis.call('pexpire', key, ttlMs)
44
+ return 1
45
+ else
46
+ return 0
47
+ end
48
+ `;
49
+ /**
50
+ * Lua script for atomic release.
51
+ *
52
+ * Removes all permits held by this holder and optionally publishes a notification.
53
+ *
54
+ * Arguments:
55
+ * - KEYS[1]: the semaphore key
56
+ * - KEYS[2]: the release notification channel
57
+ * - ARGV[1]: permits to release
58
+ * - ARGV[2]: holderId
59
+ * - ARGV[3]: shouldPublish (1 = publish, 0 = don't publish)
60
+ *
61
+ * Returns the number of permits released.
62
+ */
63
+ const RELEASE_SCRIPT = `
64
+ local key = KEYS[1]
65
+ local channel = KEYS[2]
66
+ local permits = tonumber(ARGV[1])
67
+ local holderId = ARGV[2]
68
+ local shouldPublish = tonumber(ARGV[3]) == 1
69
+ local args = {}
70
+
71
+ for i = 0, permits - 1 do
72
+ table.insert(args, holderId .. '_' .. i)
73
+ end
74
+
75
+ local released = redis.call('zrem', key, unpack(args))
76
+
77
+ -- Notify waiters that permits may be available
78
+ if released > 0 and shouldPublish then
79
+ redis.call('publish', channel, released)
80
+ end
81
+
82
+ return released
83
+ `;
84
+ /**
85
+ * Lua script for atomic TTL refresh.
86
+ *
87
+ * Updates the timestamp (score) for all permits held by this holder.
88
+ * Returns 0 if the holder doesn't have any permits (lock was lost).
89
+ *
90
+ * Arguments:
91
+ * - KEYS[1]: the semaphore key
92
+ * - ARGV[1]: limit (for consistency, though not strictly needed for refresh)
93
+ * - ARGV[2]: permits
94
+ * - ARGV[3]: holderId
95
+ * - ARGV[4]: ttlMs
96
+ * - ARGV[5]: now
97
+ *
98
+ * Returns 1 if refreshed, 0 if permits were lost.
99
+ */
100
+ const REFRESH_SCRIPT = `
101
+ local key = KEYS[1]
102
+ local limit = tonumber(ARGV[1])
103
+ local permits = tonumber(ARGV[2])
104
+ local holderId = ARGV[3]
105
+ local ttlMs = tonumber(ARGV[4])
106
+ local now = tonumber(ARGV[5])
107
+ local expiredTimestamp = now - ttlMs
108
+
109
+ -- Remove expired entries
110
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
111
+
112
+ -- Check if we still hold the first permit (indicator that we still own it)
113
+ if redis.call('zscore', key, holderId .. '_0') then
114
+ -- Update all permits with new timestamp
115
+ local args = {}
116
+ for i = 0, permits - 1 do
117
+ table.insert(args, now)
118
+ table.insert(args, holderId .. '_' .. i)
119
+ end
120
+ redis.call('zadd', key, unpack(args))
121
+ redis.call('pexpire', key, ttlMs)
122
+ return 1
123
+ else
124
+ return 0
125
+ end
126
+ `;
127
+ /**
128
+ * Lua script to get the current count of active permits.
129
+ *
130
+ * Arguments:
131
+ * - KEYS[1]: the semaphore key
132
+ * - ARGV[1]: ttlMs
133
+ * - ARGV[2]: now
134
+ *
135
+ * Returns the number of active (non-expired) permits.
136
+ */
137
+ const GET_COUNT_SCRIPT = `
138
+ local key = KEYS[1]
139
+ local ttlMs = tonumber(ARGV[1])
140
+ local now = tonumber(ARGV[2])
141
+ local expiredTimestamp = now - ttlMs
142
+
143
+ -- Remove expired entries
144
+ redis.call('zremrangebyscore', key, '-inf', expiredTimestamp)
145
+
146
+ return redis.call('zcard', key)
147
+ `;
148
+ /**
149
+ * Create a Redis-backed distributed semaphore backing layer.
150
+ *
151
+ * **Important:** This implementation is for single-instance Redis only.
152
+ * It does not implement the Redlock algorithm and should not be used with
153
+ * Redis Cluster or Redis Sentinel for distributed locking guarantees.
154
+ * For multi-instance Redis, consider implementing a Redlock-based backing.
155
+ *
156
+ * @param redis - An ioredis client instance (single instance, not cluster)
157
+ * @param options - Configuration options
158
+ */
159
+ export const layer = (redis, options = {}) => {
160
+ const keyPrefix = options.keyPrefix ?? "semaphore:";
161
+ const pushBasedAcquireEnabled = options.pushBasedAcquireEnabled ?? true;
162
+ const pushStreamRetrySchedule = options.pushStreamRetrySchedule ?? Schedule.forever;
163
+ const prefixKey = (key) => `${keyPrefix}${key}`;
164
+ const releaseChannel = (key) => `${keyPrefix}${key}:released`;
165
+ const tryAcquire = (key, holderId, ttl, limit, permits) => Effect.tryPromise({
166
+ try: async () => {
167
+ const now = Date.now();
168
+ const result = await redis.eval(ACQUIRE_SCRIPT, 1, prefixKey(key), limit.toString(), permits.toString(), holderId, Duration.toMillis(ttl).toString(), now.toString());
169
+ return result === 1;
170
+ },
171
+ catch: (cause) => new SemaphoreBackingError({ operation: "tryAcquire", cause }),
172
+ });
173
+ const release = (key, holderId, permits) => Effect.tryPromise({
174
+ try: async () => {
175
+ const result = await redis.eval(RELEASE_SCRIPT, 2, prefixKey(key), releaseChannel(key), permits.toString(), holderId, pushBasedAcquireEnabled ? "1" : "0");
176
+ return result;
177
+ },
178
+ catch: (cause) => new SemaphoreBackingError({ operation: "release", cause }),
179
+ });
180
+ const refresh = (key, holderId, ttl, limit, permits) => Effect.tryPromise({
181
+ try: async () => {
182
+ const now = Date.now();
183
+ const result = await redis.eval(REFRESH_SCRIPT, 1, prefixKey(key), limit.toString(), permits.toString(), holderId, Duration.toMillis(ttl).toString(), now.toString());
184
+ return result === 1;
185
+ },
186
+ catch: (cause) => new SemaphoreBackingError({ operation: "refresh", cause }),
187
+ });
188
+ const getCount = (key, ttl) => Effect.tryPromise({
189
+ try: async () => {
190
+ const now = Date.now();
191
+ const result = await redis.eval(GET_COUNT_SCRIPT, 1, prefixKey(key), Duration.toMillis(ttl).toString(), now.toString());
192
+ return result;
193
+ },
194
+ catch: (cause) => new SemaphoreBackingError({ operation: "getCount", cause }),
195
+ });
196
+ // Stream that emits when permits are released on a given key.
197
+ // Uses Redis pub/sub with a dedicated subscriber connection.
198
+ const onPermitsReleased = (key) => Stream.asyncPush((emit) => {
199
+ const channel = releaseChannel(key);
200
+ return Effect.acquireRelease(Effect.gen(function* () {
201
+ // Create a dedicated subscriber connection
202
+ const subscriber = redis.duplicate();
203
+ // Set up message handler before subscribing
204
+ const messageHandler = (ch, _message) => {
205
+ if (ch === channel) {
206
+ emit.single(void 0);
207
+ }
208
+ };
209
+ subscriber.on("message", messageHandler);
210
+ // Subscribe to the channel
211
+ yield* Effect.tryPromise({
212
+ try: () => subscriber.subscribe(channel),
213
+ catch: (cause) => new SemaphoreBackingError({ operation: "subscribe", cause }),
214
+ });
215
+ return { subscriber, messageHandler };
216
+ }), ({ subscriber, messageHandler }) => Effect.promise(async () => {
217
+ subscriber.off("message", messageHandler);
218
+ await subscriber.unsubscribe(channel);
219
+ subscriber.disconnect();
220
+ }));
221
+ }).pipe(Stream.retry(pushStreamRetrySchedule),
222
+ // If we continue to error, and the schedule has completed,
223
+ // just return a stream that will never emit
224
+ Stream.catchTag("SemaphoreBackingError", () => Stream.never));
225
+ return Layer.succeed(DistributedSemaphoreBacking, {
226
+ tryAcquire,
227
+ release,
228
+ refresh,
229
+ getCount,
230
+ onPermitsReleased: pushBasedAcquireEnabled ? onPermitsReleased : undefined,
231
+ });
232
+ };
233
+ //# sourceMappingURL=RedisBacking.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RedisBacking.js","sourceRoot":"","sources":["../src/RedisBacking.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Effect Distributed Semaphore
3
+ *
4
+ * A distributed semaphore library for Effect with pluggable backends.
5
+ * Implements a multi-semaphore that can be used to implement:
6
+ * - Mutex (limit=1, permits=1)
7
+ * - Semaphore (limit=N, permits=1)
8
+ * - Multi-semaphore (limit=N, permits=M)
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { DistributedSemaphore, RedisBacking } from "effect-distributed-lock";
13
+ * import { Effect } from "effect";
14
+ * import Redis from "ioredis";
15
+ *
16
+ * const redis = new Redis(process.env.REDIS_URL);
17
+ *
18
+ * const program = Effect.gen(function* () {
19
+ * // Create a semaphore that allows 5 concurrent operations
20
+ * const sem = yield* DistributedSemaphore.make("my-resource", {
21
+ * limit: 5,
22
+ * ttl: "30 seconds",
23
+ * });
24
+ *
25
+ * // Acquire 2 permits, run effect, release when done
26
+ * yield* sem.withPermits(2)(
27
+ * Effect.gen(function* () {
28
+ * // Only 2 of the 5 slots are used
29
+ * yield* doSomethingLimited();
30
+ * })
31
+ * );
32
+ *
33
+ * // For mutex behavior, use limit=1 and withPermits(1)
34
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
35
+ * yield* mutex.withPermits(1)(criticalSection);
36
+ * });
37
+ *
38
+ * program.pipe(
39
+ * Effect.provide(RedisBacking.layer(redis)),
40
+ * Effect.runPromise
41
+ * );
42
+ * ```
43
+ *
44
+ * @module
45
+ */
46
+ export * as Backing from "./Backing.js";
47
+ export { DistributedSemaphoreBacking, SemaphoreBackingError, } from "./Backing.js";
48
+ export * as DistributedSemaphore from "./DistributedSemaphore.js";
49
+ export { LockLostError } from "./Errors.js";
50
+ export * as RedisBacking from "./RedisBacking.js";
51
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAGH,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AACxC,OAAO,EACL,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,cAAc,CAAC;AAGtB,OAAO,KAAK,oBAAoB,MAAM,2BAA2B,CAAC;AAGlE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,YAAY,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Effect Distributed Semaphore
3
+ *
4
+ * A distributed semaphore library for Effect with pluggable backends.
5
+ * Implements a multi-semaphore that can be used to implement:
6
+ * - Mutex (limit=1, permits=1)
7
+ * - Semaphore (limit=N, permits=1)
8
+ * - Multi-semaphore (limit=N, permits=M)
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { DistributedSemaphore, RedisBacking } from "effect-distributed-lock";
13
+ * import { Effect } from "effect";
14
+ * import Redis from "ioredis";
15
+ *
16
+ * const redis = new Redis(process.env.REDIS_URL);
17
+ *
18
+ * const program = Effect.gen(function* () {
19
+ * // Create a semaphore that allows 5 concurrent operations
20
+ * const sem = yield* DistributedSemaphore.make("my-resource", {
21
+ * limit: 5,
22
+ * ttl: "30 seconds",
23
+ * });
24
+ *
25
+ * // Acquire 2 permits, run effect, release when done
26
+ * yield* sem.withPermits(2)(
27
+ * Effect.gen(function* () {
28
+ * // Only 2 of the 5 slots are used
29
+ * yield* doSomethingLimited();
30
+ * })
31
+ * );
32
+ *
33
+ * // For mutex behavior, use limit=1 and withPermits(1)
34
+ * const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
35
+ * yield* mutex.withPermits(1)(criticalSection);
36
+ * });
37
+ *
38
+ * program.pipe(
39
+ * Effect.provide(RedisBacking.layer(redis)),
40
+ * Effect.runPromise
41
+ * );
42
+ * ```
43
+ *
44
+ * @module
45
+ */
46
+ // Backing interface
47
+ export * as Backing from "./Backing.js";
48
+ export { DistributedSemaphoreBacking, SemaphoreBackingError, } from "./Backing.js";
49
+ // Core module (namespace with types and functions)
50
+ export * as DistributedSemaphore from "./DistributedSemaphore.js";
51
+ // Errors
52
+ export { LockLostError } from "./Errors.js";
53
+ // Redis backing
54
+ export * as RedisBacking from "./RedisBacking.js";
55
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,oBAAoB;AACpB,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AACxC,OAAO,EACL,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,cAAc,CAAC;AAEtB,mDAAmD;AACnD,OAAO,KAAK,oBAAoB,MAAM,2BAA2B,CAAC;AAElE,SAAS;AACT,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,gBAAgB;AAChB,OAAO,KAAK,YAAY,MAAM,mBAAmB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-distributed-lock",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "A distributed semaphore library for Effect with pluggable backends",
5
5
  "license": "MIT",
6
6
  "repository": {
package/bun.lock DELETED
@@ -1,64 +0,0 @@
1
- {
2
- "lockfileVersion": 1,
3
- "workspaces": {
4
- "": {
5
- "name": "effect-distributed-lock",
6
- "dependencies": {
7
- "effect": "^3.19.13",
8
- },
9
- "devDependencies": {
10
- "@types/bun": "latest",
11
- "ioredis": "^5.4.1",
12
- },
13
- "peerDependencies": {
14
- "effect": "^3.0.0",
15
- "ioredis": "^5.0.0",
16
- "typescript": "^5",
17
- },
18
- "optionalPeers": [
19
- "ioredis",
20
- ],
21
- },
22
- },
23
- "packages": {
24
- "@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
25
-
26
- "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
27
-
28
- "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
29
-
30
- "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
31
-
32
- "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
33
-
34
- "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
35
-
36
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
37
-
38
- "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
39
-
40
- "effect": ["effect@3.19.13", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-8MZ783YuHRwHZX2Mmm+bpGxq+7XPd88sWwYAz2Ysry80sEKpftDZXs2Hg9ZyjESi1IBTNHF0oDKe0zJRkUlyew=="],
41
-
42
- "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
43
-
44
- "ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
45
-
46
- "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
47
-
48
- "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
49
-
50
- "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
51
-
52
- "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
53
-
54
- "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
55
-
56
- "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
57
-
58
- "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
59
-
60
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
61
-
62
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
63
- }
64
- }
@@ -1,119 +0,0 @@
1
- /**
2
- * Example usage of the distributed semaphore library.
3
- *
4
- * Run with: bun run example.ts
5
- * Requires REDIS_URL environment variable.
6
- */
7
- import { Effect, Console, Duration, Option } from "effect";
8
- import Redis from "ioredis";
9
- import { DistributedSemaphore, RedisBacking } from "../src/index.ts";
10
-
11
- // Create Redis client
12
- const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
13
-
14
- // Create the Redis backing layer
15
- const RedisLayer = RedisBacking.layer(redis, {
16
- keyPrefix: "example:",
17
- });
18
-
19
- // Example 1: Using withPermits for a critical section (mutex behavior)
20
- const example1 = Effect.gen(function* () {
21
- yield* Console.log("=== Example 1: Mutex with withPermits(1) ===");
22
-
23
- const mutex = yield* DistributedSemaphore.make("my-resource", {
24
- limit: 1, // Acts as a mutex
25
- ttl: Duration.seconds(10),
26
- });
27
-
28
- yield* mutex.withPermits(1)(
29
- Effect.gen(function* () {
30
- yield* Console.log("Lock acquired! Doing critical work...");
31
- yield* Effect.sleep(2000);
32
- yield* Console.log("Critical work done!");
33
- })
34
- );
35
-
36
- yield* Console.log("Lock released automatically");
37
- });
38
-
39
- // Example 2: Using withPermitsIfAvailable (non-blocking)
40
- const example2 = Effect.gen(function* () {
41
- yield* Console.log("\n=== Example 2: withPermitsIfAvailable ===");
42
-
43
- const mutex = yield* DistributedSemaphore.make("another-resource", {
44
- limit: 1,
45
- ttl: Duration.seconds(10),
46
- });
47
-
48
- const result = yield* mutex.withPermitsIfAvailable(1)(
49
- Effect.gen(function* () {
50
- yield* Console.log("Got the lock without waiting!");
51
- return "success";
52
- })
53
- );
54
-
55
- if (Option.isSome(result)) {
56
- yield* Console.log(`Result: ${result.value}`);
57
- } else {
58
- yield* Console.log("Could not acquire lock immediately");
59
- }
60
- });
61
-
62
- // Example 3: Semaphore with multiple permits
63
- const example3 = Effect.gen(function* () {
64
- yield* Console.log("\n=== Example 3: Semaphore with limit=5 ===");
65
-
66
- const sem = yield* DistributedSemaphore.make("pool-resource", {
67
- limit: 5, // Allow 5 concurrent operations
68
- ttl: Duration.seconds(10),
69
- });
70
-
71
- // Acquire 2 permits out of 5
72
- yield* sem.withPermits(2)(
73
- Effect.gen(function* () {
74
- yield* Console.log("Acquired 2 permits (3 still available)");
75
- const count = yield* sem.currentCount;
76
- yield* Console.log(`Current active permits: ${count}`);
77
- yield* Effect.sleep(1000);
78
- })
79
- );
80
-
81
- yield* Console.log("Released 2 permits");
82
- });
83
-
84
- // Example 4: Manual scope management with take()
85
- const example4 = Effect.gen(function* () {
86
- yield* Console.log("\n=== Example 4: Manual acquire with Scope ===");
87
-
88
- const mutex = yield* DistributedSemaphore.make("manual-resource", {
89
- limit: 1,
90
- ttl: Duration.seconds(10),
91
- });
92
-
93
- // Using Effect.scoped to manage the lock lifecycle
94
- yield* Effect.scoped(
95
- Effect.gen(function* () {
96
- yield* mutex.take(1);
97
- yield* Console.log("Permit acquired via take(1)");
98
- yield* Effect.sleep(1000);
99
- yield* Console.log("About to exit scope...");
100
- // Permits are automatically released when scope closes
101
- })
102
- );
103
-
104
- yield* Console.log("Scope closed, permit released");
105
- });
106
-
107
- // Run all examples
108
- const main = Effect.gen(function* () {
109
- yield* example1;
110
- yield* example2;
111
- yield* example3;
112
- yield* example4;
113
- yield* Console.log("\n✓ All examples completed!");
114
- }).pipe(
115
- Effect.ensuring(Effect.promise(() => redis.quit())),
116
- Effect.provide(RedisLayer)
117
- );
118
-
119
- Effect.runPromise(main).catch(console.error);
package/examples/push.ts DELETED
@@ -1,109 +0,0 @@
1
- /**
2
- * Demonstrates concurrent effects competing for a distributed lock.
3
- *
4
- * This example runs two scenarios:
5
- * 1. With push-based acquisition DISABLED (polling only)
6
- * 2. With push-based acquisition ENABLED (pub/sub notifications)
7
- *
8
- * You'll see how push-based acquisition is faster because waiters are
9
- * notified immediately when permits are released, rather than polling.
10
- *
11
- * Run with: bun run examples/push.ts
12
- * Requires REDIS_URL environment variable or local Redis at localhost:6379.
13
- */
14
- import { Console, Duration, Effect, Schedule } from "effect";
15
- import Redis from "ioredis";
16
- import { DistributedSemaphore, RedisBacking } from "../src/index.ts";
17
-
18
- const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
19
-
20
- // Helper to create a task that competes for the lock
21
- const makeTask = (
22
- id: number,
23
- mutex: DistributedSemaphore.DistributedSemaphore
24
- ) =>
25
- Effect.gen(function* () {
26
- yield* Console.log(`[Task ${id}] Starting, waiting for lock...`);
27
- const startWait = Date.now();
28
-
29
- yield* mutex.withPermits(1)(
30
- Effect.gen(function* () {
31
- const waitTime = Date.now() - startWait;
32
- yield* Console.log(
33
- `[Task ${id}] 🔒 Lock acquired! (waited ${waitTime}ms)`
34
- );
35
-
36
- // Simulate some work
37
- yield* Effect.sleep(Duration.millis(200));
38
-
39
- yield* Console.log(`[Task ${id}] 🔓 Releasing lock...`);
40
- })
41
- );
42
-
43
- yield* Console.log(`[Task ${id}] Done`);
44
- });
45
-
46
- // Run a scenario with the given configuration
47
- const runScenario = (name: string, pushEnabled: boolean) =>
48
- Effect.gen(function* () {
49
- yield* Console.log(`\n${"=".repeat(60)}`);
50
- yield* Console.log(`${name}`);
51
- yield* Console.log(`Push-based acquisition: ${pushEnabled ? "ON" : "OFF"}`);
52
- yield* Console.log(`${"=".repeat(60)}\n`);
53
-
54
- const startTime = Date.now();
55
-
56
- // Create mutex with a unique key per scenario to avoid interference
57
- const mutex = yield* DistributedSemaphore.make(
58
- `concurrent-example-${pushEnabled ? "push" : "poll"}`,
59
- {
60
- acquireRetryPolicy: Schedule.spaced(Duration.millis(500)),
61
- limit: 1, // Mutex - only one holder at a time
62
- }
63
- );
64
-
65
- // Run 3 tasks concurrently, all competing for the same lock
66
- yield* Effect.all(
67
- [makeTask(1, mutex), makeTask(2, mutex), makeTask(3, mutex)],
68
- { concurrency: 3 }
69
- );
70
-
71
- const totalTime = Date.now() - startTime;
72
- yield* Console.log(`\n⏱️ Total time: ${totalTime}ms\n`);
73
- });
74
-
75
- // Run both scenarios
76
- const main = Effect.gen(function* () {
77
- yield* Console.log("🚀 Distributed Lock Concurrency Demo");
78
- yield* Console.log(
79
- "Showing 3 concurrent tasks competing for a mutex (limit=1)"
80
- );
81
-
82
- // Run WITHOUT push (polling only)
83
- const RedisLayerNoPush = RedisBacking.layer(redis, {
84
- keyPrefix: "concurrent-demo:",
85
- pushBasedAcquireEnabled: false,
86
- });
87
- yield* runScenario("Scenario 1: Polling Only", false).pipe(
88
- Effect.provide(RedisLayerNoPush)
89
- );
90
-
91
- // Run WITH push (pub/sub notifications)
92
- const RedisLayerWithPush = RedisBacking.layer(redis, {
93
- keyPrefix: "concurrent-demo:",
94
- pushBasedAcquireEnabled: true,
95
- });
96
- yield* runScenario("Scenario 2: Push-Based (Pub/Sub)", true).pipe(
97
- Effect.provide(RedisLayerWithPush)
98
- );
99
-
100
- yield* Console.log("✅ Demo complete!");
101
- yield* Console.log(
102
- "Notice how push-based acquisition completes faster because"
103
- );
104
- yield* Console.log(
105
- "waiters are notified immediately instead of waiting for the next poll.\n"
106
- );
107
- }).pipe(Effect.ensuring(Effect.promise(() => redis.quit())));
108
-
109
- Effect.runPromise(main).catch(console.error);
package/tsconfig.json DELETED
@@ -1,35 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- // Environment setup & latest features
4
- "lib": ["ESNext"],
5
- "target": "ESNext",
6
- "module": "NodeNext",
7
- "moduleDetection": "force",
8
- "jsx": "react-jsx",
9
- "allowJs": true,
10
-
11
- // Bundler mode
12
- "moduleResolution": "nodenext",
13
- "verbatimModuleSyntax": true,
14
-
15
- // Build output
16
- "rootDir": "src",
17
- "outDir": "dist",
18
- "declaration": true,
19
- "declarationMap": true,
20
- "sourceMap": true,
21
-
22
- // Best practices
23
- "strict": true,
24
- "skipLibCheck": true,
25
- "noFallthroughCasesInSwitch": true,
26
- "noUncheckedIndexedAccess": true,
27
-
28
- // Some stricter flags (disabled by default)
29
- "noUnusedLocals": false,
30
- "noUnusedParameters": false,
31
- "noPropertyAccessFromIndexSignature": false
32
- },
33
- "include": ["src"],
34
- "exclude": ["node_modules", "dist"]
35
- }