effect-distributed-lock 0.0.3 → 0.0.4
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 +90 -56
- package/examples/index.ts +49 -21
- package/package.json +2 -2
- package/redis-semaphore/.codeclimate.yml +5 -0
- package/redis-semaphore/.fossa.yml +14 -0
- package/redis-semaphore/.github/dependabot.yml +6 -0
- package/redis-semaphore/.github/workflows/branches.yml +39 -0
- package/redis-semaphore/.github/workflows/pull-requests.yml +35 -0
- package/redis-semaphore/.mocharc.yaml +6 -0
- package/redis-semaphore/.prettierrc +6 -0
- package/redis-semaphore/.snyk +4 -0
- package/redis-semaphore/.yarnrc.yml +2 -0
- package/redis-semaphore/CHANGELOG.md +70 -0
- package/redis-semaphore/Dockerfile +5 -0
- package/redis-semaphore/LICENSE +21 -0
- package/redis-semaphore/README.md +445 -0
- package/redis-semaphore/docker-compose.yml +31 -0
- package/redis-semaphore/eslint.config.mjs +73 -0
- package/redis-semaphore/package.json +79 -0
- package/redis-semaphore/setup-redis-servers.sh +2 -0
- package/redis-semaphore/src/Lock.ts +172 -0
- package/redis-semaphore/src/RedisMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedisMutex.ts +45 -0
- package/redis-semaphore/src/RedisSemaphore.ts +49 -0
- package/redis-semaphore/src/RedlockMultiSemaphore.ts +56 -0
- package/redis-semaphore/src/RedlockMutex.ts +52 -0
- package/redis-semaphore/src/RedlockSemaphore.ts +49 -0
- package/redis-semaphore/src/errors/LostLockError.ts +1 -0
- package/redis-semaphore/src/errors/TimeoutError.ts +1 -0
- package/redis-semaphore/src/index.ts +23 -0
- package/redis-semaphore/src/misc.ts +12 -0
- package/redis-semaphore/src/multiSemaphore/acquire/index.ts +53 -0
- package/redis-semaphore/src/multiSemaphore/acquire/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/refresh/index.ts +32 -0
- package/redis-semaphore/src/multiSemaphore/refresh/lua.ts +31 -0
- package/redis-semaphore/src/multiSemaphore/release/index.ts +22 -0
- package/redis-semaphore/src/multiSemaphore/release/lua.ts +17 -0
- package/redis-semaphore/src/mutex/acquire.ts +42 -0
- package/redis-semaphore/src/mutex/refresh.ts +37 -0
- package/redis-semaphore/src/mutex/release.ts +30 -0
- package/redis-semaphore/src/redlockMultiSemaphore/acquire.ts +56 -0
- package/redis-semaphore/src/redlockMultiSemaphore/refresh.ts +68 -0
- package/redis-semaphore/src/redlockMultiSemaphore/release.ts +19 -0
- package/redis-semaphore/src/redlockMutex/acquire.ts +54 -0
- package/redis-semaphore/src/redlockMutex/refresh.ts +53 -0
- package/redis-semaphore/src/redlockMutex/release.ts +19 -0
- package/redis-semaphore/src/redlockSemaphore/acquire.ts +55 -0
- package/redis-semaphore/src/redlockSemaphore/refresh.ts +60 -0
- package/redis-semaphore/src/redlockSemaphore/release.ts +18 -0
- package/redis-semaphore/src/semaphore/acquire/index.ts +52 -0
- package/redis-semaphore/src/semaphore/acquire/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/refresh/index.ts +31 -0
- package/redis-semaphore/src/semaphore/refresh/lua.ts +25 -0
- package/redis-semaphore/src/semaphore/release.ts +14 -0
- package/redis-semaphore/src/types.ts +63 -0
- package/redis-semaphore/src/utils/createEval.ts +45 -0
- package/redis-semaphore/src/utils/index.ts +13 -0
- package/redis-semaphore/src/utils/redlock.ts +7 -0
- package/redis-semaphore/test/init.test.ts +9 -0
- package/redis-semaphore/test/redisClient.ts +82 -0
- package/redis-semaphore/test/setup.ts +6 -0
- package/redis-semaphore/test/shell.test.ts +15 -0
- package/redis-semaphore/test/shell.ts +48 -0
- package/redis-semaphore/test/src/Lock.test.ts +37 -0
- package/redis-semaphore/test/src/RedisMultiSemaphore.test.ts +425 -0
- package/redis-semaphore/test/src/RedisMutex.test.ts +334 -0
- package/redis-semaphore/test/src/RedisSemaphore.test.ts +367 -0
- package/redis-semaphore/test/src/RedlockMultiSemaphore.test.ts +671 -0
- package/redis-semaphore/test/src/RedlockMutex.test.ts +328 -0
- package/redis-semaphore/test/src/RedlockSemaphore.test.ts +579 -0
- package/redis-semaphore/test/src/index.test.ts +22 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/index.test.ts +51 -0
- package/redis-semaphore/test/src/multiSemaphore/acquire/internal.test.ts +67 -0
- package/redis-semaphore/test/src/multiSemaphore/refresh/index.test.ts +52 -0
- package/redis-semaphore/test/src/multiSemaphore/release/index.test.ts +18 -0
- package/redis-semaphore/test/src/mutex/acquire.test.ts +78 -0
- package/redis-semaphore/test/src/mutex/refresh.test.ts +22 -0
- package/redis-semaphore/test/src/mutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/redlockMutex/acquire.test.ts +90 -0
- package/redis-semaphore/test/src/redlockMutex/refresh.test.ts +27 -0
- package/redis-semaphore/test/src/redlockMutex/release.test.ts +17 -0
- package/redis-semaphore/test/src/semaphore/acquire/index.test.ts +49 -0
- package/redis-semaphore/test/src/semaphore/acquire/internal.test.ts +65 -0
- package/redis-semaphore/test/src/semaphore/refresh/index.test.ts +44 -0
- package/redis-semaphore/test/src/semaphore/release.test.ts +18 -0
- package/redis-semaphore/test/src/utils/eval.test.ts +22 -0
- package/redis-semaphore/test/src/utils/index.test.ts +19 -0
- package/redis-semaphore/test/src/utils/redlock.test.ts +31 -0
- package/redis-semaphore/test/unhandledRejection.ts +28 -0
- package/redis-semaphore/tsconfig.build-commonjs.json +9 -0
- package/redis-semaphore/tsconfig.build-es.json +9 -0
- package/redis-semaphore/tsconfig.json +11 -0
- package/redis-semaphore/yarn.lock +5338 -0
- package/src/Backing.ts +87 -0
- package/src/DistributedSemaphore.ts +384 -0
- package/src/Errors.ts +3 -15
- package/src/RedisBacking.ts +165 -59
- package/src/index.ts +28 -12
- package/src/DistributedMutex.ts +0 -356
package/src/Backing.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Context, Data, Duration, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Errors
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Error from the backing store (Redis, etc.)
|
|
9
|
+
*/
|
|
10
|
+
export class SemaphoreBackingError extends Data.TaggedError(
|
|
11
|
+
"SemaphoreBackingError"
|
|
12
|
+
)<{
|
|
13
|
+
readonly operation: string;
|
|
14
|
+
readonly cause: unknown;
|
|
15
|
+
}> {
|
|
16
|
+
get message() {
|
|
17
|
+
return `Backing store error during "${this.operation}": ${this.cause}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Backing Service
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Low-level backing store interface for distributed semaphore operations.
|
|
27
|
+
* Implementations handle the actual storage (Redis, etc.)
|
|
28
|
+
*
|
|
29
|
+
* The semaphore uses a sorted set model where:
|
|
30
|
+
* - Each permit holder is stored with their acquisition timestamp as the score
|
|
31
|
+
* - Expired entries are cleaned up automatically
|
|
32
|
+
* - Multiple permits can be acquired atomically
|
|
33
|
+
*/
|
|
34
|
+
export interface DistributedSemaphoreBacking {
|
|
35
|
+
/**
|
|
36
|
+
* Try to acquire `permits` from a semaphore with the given `limit`.
|
|
37
|
+
* Returns true if acquired, false if not enough permits available.
|
|
38
|
+
*
|
|
39
|
+
* The implementation should:
|
|
40
|
+
* 1. Clean up expired entries (based on TTL)
|
|
41
|
+
* 2. Check if there's room: currentCount + permits <= limit
|
|
42
|
+
* 3. If so, add the permits with current timestamp
|
|
43
|
+
*/
|
|
44
|
+
readonly tryAcquire: (
|
|
45
|
+
key: string,
|
|
46
|
+
holderId: string,
|
|
47
|
+
ttl: Duration.Duration,
|
|
48
|
+
limit: number,
|
|
49
|
+
permits: number
|
|
50
|
+
) => Effect.Effect<boolean, SemaphoreBackingError>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Release `permits` held by this holder.
|
|
54
|
+
* Returns the number of permits actually released.
|
|
55
|
+
*/
|
|
56
|
+
readonly release: (
|
|
57
|
+
key: string,
|
|
58
|
+
holderId: string,
|
|
59
|
+
permits: number
|
|
60
|
+
) => Effect.Effect<number, SemaphoreBackingError>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refresh the TTL on permits we hold.
|
|
64
|
+
* Returns true if refreshed, false if permits were lost.
|
|
65
|
+
*/
|
|
66
|
+
readonly refresh: (
|
|
67
|
+
key: string,
|
|
68
|
+
holderId: string,
|
|
69
|
+
ttl: Duration.Duration,
|
|
70
|
+
limit: number,
|
|
71
|
+
permits: number
|
|
72
|
+
) => Effect.Effect<boolean, SemaphoreBackingError>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the number of permits currently held (in use).
|
|
76
|
+
* Available permits = limit - getCount().
|
|
77
|
+
*/
|
|
78
|
+
readonly getCount: (
|
|
79
|
+
key: string,
|
|
80
|
+
ttl: Duration.Duration
|
|
81
|
+
) => Effect.Effect<number, SemaphoreBackingError>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const DistributedSemaphoreBacking =
|
|
85
|
+
Context.GenericTag<DistributedSemaphoreBacking>(
|
|
86
|
+
"@effect-distributed-lock/DistributedSemaphoreBacking"
|
|
87
|
+
);
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Deferred,
|
|
3
|
+
Duration,
|
|
4
|
+
Effect,
|
|
5
|
+
Fiber,
|
|
6
|
+
Option,
|
|
7
|
+
Schedule,
|
|
8
|
+
Scope,
|
|
9
|
+
} from "effect";
|
|
10
|
+
import {
|
|
11
|
+
DistributedSemaphoreBacking,
|
|
12
|
+
SemaphoreBackingError,
|
|
13
|
+
} from "./Backing.js";
|
|
14
|
+
import { LockLostError, NotYetAcquiredError } from "./Errors.js";
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Semaphore Configuration
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface DistributedSemaphoreConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Maximum number of permits available.
|
|
23
|
+
* This is the total capacity of the semaphore.
|
|
24
|
+
* @default 1 (mutex behavior)
|
|
25
|
+
*/
|
|
26
|
+
readonly limit?: number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TTL for held permits. If the holder crashes, permits auto-release after this.
|
|
30
|
+
* @default 30 seconds
|
|
31
|
+
*/
|
|
32
|
+
readonly ttl?: Duration.DurationInput;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How often to refresh the TTL while holding permits.
|
|
36
|
+
* Should be less than TTL to avoid losing the permits.
|
|
37
|
+
* @default 1/3 of TTL
|
|
38
|
+
*/
|
|
39
|
+
readonly refreshInterval?: Duration.DurationInput;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How often to poll when waiting to acquire permits.
|
|
43
|
+
* @default 100ms
|
|
44
|
+
*/
|
|
45
|
+
readonly acquireRetryInterval?: Duration.DurationInput;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Retry policy when a backing failure occurs.
|
|
49
|
+
* This could happen when:
|
|
50
|
+
* - Trying to acquire permits
|
|
51
|
+
* - Refreshing the TTL
|
|
52
|
+
* - Releasing permits
|
|
53
|
+
*/
|
|
54
|
+
readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_LIMIT = 1;
|
|
58
|
+
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);
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Distributed Semaphore Interface
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A distributed semaphore that can be used across multiple processes/services.
|
|
70
|
+
*
|
|
71
|
+
* Similar to Effect's built-in Semaphore, but distributed across processes
|
|
72
|
+
* using a backing store like Redis.
|
|
73
|
+
*
|
|
74
|
+
* A semaphore manages a pool of permits. Tasks can acquire one or more permits,
|
|
75
|
+
* and must release them when done. If not enough permits are available, tasks
|
|
76
|
+
* wait until permits are released.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // Create a semaphore that allows 5 concurrent operations
|
|
81
|
+
* const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
|
|
82
|
+
*
|
|
83
|
+
* // Acquire 2 permits, run effect, then release
|
|
84
|
+
* yield* sem.withPermits(2)(myEffect);
|
|
85
|
+
*
|
|
86
|
+
* // Create a mutex (limit=1) for exclusive access
|
|
87
|
+
* const mutex = yield* DistributedSemaphore.make("my-lock", { limit: 1 });
|
|
88
|
+
* yield* mutex.withPermits(1)(criticalSection);
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export interface DistributedSemaphore {
|
|
92
|
+
/**
|
|
93
|
+
* The key identifying this semaphore in the backing store.
|
|
94
|
+
*/
|
|
95
|
+
readonly key: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The maximum number of permits available.
|
|
99
|
+
*/
|
|
100
|
+
readonly limit: number;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run an effect with the specified number of permits.
|
|
104
|
+
* Acquires the permits, runs the effect, then releases.
|
|
105
|
+
* If not enough permits are available, waits until they are.
|
|
106
|
+
*
|
|
107
|
+
* The permit TTL is refreshed automatically while the effect runs.
|
|
108
|
+
*/
|
|
109
|
+
readonly withPermits: (
|
|
110
|
+
permits: number
|
|
111
|
+
) => <A, E, R>(
|
|
112
|
+
effect: Effect.Effect<A, E, R>
|
|
113
|
+
) => Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run an effect only if the specified permits are immediately available.
|
|
117
|
+
* Returns Some(result) if permits were acquired and effect ran,
|
|
118
|
+
* None if permits were not available.
|
|
119
|
+
*/
|
|
120
|
+
readonly withPermitsIfAvailable: (
|
|
121
|
+
permits: number
|
|
122
|
+
) => <A, E, R>(
|
|
123
|
+
effect: Effect.Effect<A, E, R>
|
|
124
|
+
) => Effect.Effect<
|
|
125
|
+
Option.Option<A>,
|
|
126
|
+
E | LockLostError | SemaphoreBackingError,
|
|
127
|
+
R
|
|
128
|
+
>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Acquire the specified number of permits, waiting if necessary.
|
|
132
|
+
* The permits are held until the scope is closed.
|
|
133
|
+
* The permit TTL is refreshed automatically while held.
|
|
134
|
+
*
|
|
135
|
+
* @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
|
|
136
|
+
* When the scope closes, the fiber is interrupted and permits are released.
|
|
137
|
+
*/
|
|
138
|
+
readonly take: (
|
|
139
|
+
permits: number
|
|
140
|
+
) => Effect.Effect<
|
|
141
|
+
Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
|
|
142
|
+
LockLostError | SemaphoreBackingError,
|
|
143
|
+
Scope.Scope
|
|
144
|
+
>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Try to acquire permits immediately without waiting.
|
|
148
|
+
* Returns Some(fiber) if acquired (permits held until scope closes),
|
|
149
|
+
* None if permits were not available.
|
|
150
|
+
*
|
|
151
|
+
* @returns The fiber that refreshes the permit hold. Its lifetime is tied to the scope.
|
|
152
|
+
* When the scope closes, the fiber is interrupted and permits are released.
|
|
153
|
+
*/
|
|
154
|
+
readonly tryTake: (
|
|
155
|
+
permits: number
|
|
156
|
+
) => Effect.Effect<
|
|
157
|
+
Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
|
|
158
|
+
LockLostError | SemaphoreBackingError,
|
|
159
|
+
Scope.Scope
|
|
160
|
+
>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the current number of active permits (for debugging/introspection).
|
|
164
|
+
* Note: This is eventually consistent due to TTL-based expiration.
|
|
165
|
+
*/
|
|
166
|
+
readonly currentCount: Effect.Effect<number, SemaphoreBackingError>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// =============================================================================
|
|
170
|
+
// Factory
|
|
171
|
+
// =============================================================================
|
|
172
|
+
|
|
173
|
+
type FullyResolvedConfig = {
|
|
174
|
+
limit: number;
|
|
175
|
+
ttl: Duration.Duration;
|
|
176
|
+
refreshInterval: Duration.Duration;
|
|
177
|
+
acquireRetryInterval: Duration.Duration;
|
|
178
|
+
backingFailureRetryPolicy: Schedule.Schedule<void>;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function fullyResolveConfig(
|
|
182
|
+
config: DistributedSemaphoreConfig
|
|
183
|
+
): FullyResolvedConfig {
|
|
184
|
+
const limit = config.limit ?? DEFAULT_LIMIT;
|
|
185
|
+
const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
|
|
186
|
+
const refreshInterval = config.refreshInterval
|
|
187
|
+
? Duration.decode(config.refreshInterval)
|
|
188
|
+
: Duration.millis(Duration.toMillis(ttl) / 3);
|
|
189
|
+
const acquireRetryInterval = config.acquireRetryInterval
|
|
190
|
+
? Duration.decode(config.acquireRetryInterval)
|
|
191
|
+
: DEFAULT_ACQUIRE_RETRY_INTERVAL;
|
|
192
|
+
const backingFailureRetryPolicy = config.backingFailureRetryPolicy
|
|
193
|
+
? config.backingFailureRetryPolicy
|
|
194
|
+
: DEFAULT_FAILURE_RETRY_POLICY;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
limit,
|
|
198
|
+
ttl,
|
|
199
|
+
refreshInterval,
|
|
200
|
+
acquireRetryInterval,
|
|
201
|
+
backingFailureRetryPolicy,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Create a distributed semaphore for the given key.
|
|
207
|
+
*
|
|
208
|
+
* @param key - Unique identifier for this semaphore in the backing store
|
|
209
|
+
* @param config - Configuration options
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* // Create a semaphore that allows 5 concurrent operations
|
|
214
|
+
* const sem = yield* DistributedSemaphore.make("my-resource", { limit: 5 });
|
|
215
|
+
*
|
|
216
|
+
* // Use withPermits to run an effect with acquired permits
|
|
217
|
+
* yield* sem.withPermits(2)(Effect.log("I have 2 permits"));
|
|
218
|
+
*
|
|
219
|
+
* // Use withPermitsIfAvailable to try without waiting
|
|
220
|
+
* const result = yield* sem.withPermitsIfAvailable(1)(Effect.succeed(42));
|
|
221
|
+
* // result: Option<number>
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export const make = (
|
|
225
|
+
key: string,
|
|
226
|
+
config: DistributedSemaphoreConfig = {}
|
|
227
|
+
): Effect.Effect<DistributedSemaphore, never, DistributedSemaphoreBacking> =>
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
const backing = yield* DistributedSemaphoreBacking;
|
|
230
|
+
|
|
231
|
+
// Generate unique holder ID for this instance
|
|
232
|
+
const holderId = crypto.randomUUID();
|
|
233
|
+
|
|
234
|
+
// Resolve config with defaults
|
|
235
|
+
const {
|
|
236
|
+
limit,
|
|
237
|
+
ttl,
|
|
238
|
+
refreshInterval,
|
|
239
|
+
acquireRetryInterval,
|
|
240
|
+
backingFailureRetryPolicy,
|
|
241
|
+
} = fullyResolveConfig(config);
|
|
242
|
+
|
|
243
|
+
const withBackingErrorRetry = <A, E extends { _tag: string }, R>(
|
|
244
|
+
effect: Effect.Effect<A, E | SemaphoreBackingError, R>
|
|
245
|
+
) =>
|
|
246
|
+
effect.pipe(
|
|
247
|
+
Effect.retry({
|
|
248
|
+
while: (e) => e._tag === "SemaphoreBackingError",
|
|
249
|
+
schedule: backingFailureRetryPolicy,
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Keep the permits alive by refreshing TTL periodically.
|
|
254
|
+
// This effect runs forever until interrupted (when scope closes).
|
|
255
|
+
const keepAlive = (
|
|
256
|
+
permits: number
|
|
257
|
+
): Effect.Effect<never, SemaphoreBackingError | LockLostError, never> =>
|
|
258
|
+
Effect.repeat(
|
|
259
|
+
Effect.gen(function* () {
|
|
260
|
+
const refreshed = yield* backing
|
|
261
|
+
.refresh(key, holderId, ttl, limit, permits)
|
|
262
|
+
.pipe(withBackingErrorRetry);
|
|
263
|
+
|
|
264
|
+
if (!refreshed) {
|
|
265
|
+
return yield* new LockLostError({ key });
|
|
266
|
+
}
|
|
267
|
+
}),
|
|
268
|
+
Schedule.spaced(refreshInterval)
|
|
269
|
+
).pipe(
|
|
270
|
+
Effect.andThen(
|
|
271
|
+
Effect.dieMessage(
|
|
272
|
+
"Invariant violated: `keepAlive` should never return a value"
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Try to acquire permits immediately, returns Option
|
|
278
|
+
const tryTake = (
|
|
279
|
+
permits: number
|
|
280
|
+
): Effect.Effect<
|
|
281
|
+
Option.Option<Fiber.Fiber<never, LockLostError | SemaphoreBackingError>>,
|
|
282
|
+
SemaphoreBackingError,
|
|
283
|
+
Scope.Scope
|
|
284
|
+
> =>
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const acquired = yield* backing
|
|
287
|
+
.tryAcquire(key, holderId, ttl, limit, permits)
|
|
288
|
+
.pipe(withBackingErrorRetry);
|
|
289
|
+
if (!acquired) {
|
|
290
|
+
return Option.none();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Start keepalive fiber, tied to this scope
|
|
294
|
+
const keepAliveFiber = yield* Effect.forkScoped(keepAlive(permits));
|
|
295
|
+
|
|
296
|
+
// Add finalizer to release permits when scope closes
|
|
297
|
+
yield* Effect.addFinalizer(() =>
|
|
298
|
+
backing
|
|
299
|
+
.release(key, holderId, permits)
|
|
300
|
+
.pipe(withBackingErrorRetry, Effect.ignore)
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
return Option.some(keepAliveFiber);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Acquire permits with retry, returns fiber when acquired
|
|
307
|
+
const take = (
|
|
308
|
+
permits: number
|
|
309
|
+
): Effect.Effect<
|
|
310
|
+
Fiber.Fiber<never, LockLostError | SemaphoreBackingError>,
|
|
311
|
+
SemaphoreBackingError,
|
|
312
|
+
Scope.Scope
|
|
313
|
+
> =>
|
|
314
|
+
Effect.gen(function* () {
|
|
315
|
+
const maybeAcquired = yield* tryTake(permits);
|
|
316
|
+
if (Option.isNone(maybeAcquired)) {
|
|
317
|
+
return yield* new NotYetAcquiredError();
|
|
318
|
+
}
|
|
319
|
+
return maybeAcquired.value;
|
|
320
|
+
}).pipe(
|
|
321
|
+
Effect.retry({
|
|
322
|
+
while: (e) => e._tag === "NotYetAcquiredError",
|
|
323
|
+
schedule: Schedule.spaced(acquireRetryInterval),
|
|
324
|
+
}),
|
|
325
|
+
Effect.catchTag("NotYetAcquiredError", () =>
|
|
326
|
+
Effect.dieMessage(
|
|
327
|
+
"Invariant violated: `take` should never return `NotYetAcquiredError` " +
|
|
328
|
+
"since it should be caught by the retry which should retry forever until permits are acquired"
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// Convenience: acquire permits, run effect, release when done
|
|
334
|
+
const withPermits =
|
|
335
|
+
(permits: number) =>
|
|
336
|
+
<A, E, R>(
|
|
337
|
+
effect: Effect.Effect<A, E, R>
|
|
338
|
+
): Effect.Effect<A, E | LockLostError | SemaphoreBackingError, R> =>
|
|
339
|
+
Effect.scoped(
|
|
340
|
+
Effect.gen(function* () {
|
|
341
|
+
const keepAliveFiber = yield* take(permits);
|
|
342
|
+
|
|
343
|
+
return yield* Effect.raceFirst(effect, Fiber.join(keepAliveFiber));
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Convenience: try to acquire permits, run effect if successful
|
|
348
|
+
const withPermitsIfAvailable =
|
|
349
|
+
(permits: number) =>
|
|
350
|
+
<A, E, R>(
|
|
351
|
+
effect: Effect.Effect<A, E, R>
|
|
352
|
+
): Effect.Effect<
|
|
353
|
+
Option.Option<A>,
|
|
354
|
+
E | LockLostError | SemaphoreBackingError,
|
|
355
|
+
R
|
|
356
|
+
> =>
|
|
357
|
+
Effect.scoped(
|
|
358
|
+
Effect.gen(function* () {
|
|
359
|
+
const maybeAcquired = yield* tryTake(permits);
|
|
360
|
+
if (Option.isNone(maybeAcquired)) {
|
|
361
|
+
return Option.none();
|
|
362
|
+
}
|
|
363
|
+
const keepAliveFiber = maybeAcquired.value;
|
|
364
|
+
return yield* Effect.raceFirst(
|
|
365
|
+
effect.pipe(Effect.asSome),
|
|
366
|
+
Fiber.join(keepAliveFiber)
|
|
367
|
+
);
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const currentCount: Effect.Effect<number, SemaphoreBackingError> = backing
|
|
372
|
+
.getCount(key, ttl)
|
|
373
|
+
.pipe(withBackingErrorRetry);
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
key,
|
|
377
|
+
limit,
|
|
378
|
+
withPermits,
|
|
379
|
+
withPermitsIfAvailable,
|
|
380
|
+
take,
|
|
381
|
+
tryTake,
|
|
382
|
+
currentCount,
|
|
383
|
+
} satisfies DistributedSemaphore;
|
|
384
|
+
});
|
package/src/Errors.ts
CHANGED
|
@@ -1,30 +1,18 @@
|
|
|
1
1
|
import { Data } from "effect";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
4
|
+
* The permits were lost (TTL expired while we thought we held them)
|
|
5
5
|
*/
|
|
6
6
|
export class LockLostError extends Data.TaggedError("LockLostError")<{
|
|
7
7
|
readonly key: string;
|
|
8
8
|
}> {
|
|
9
9
|
get message() {
|
|
10
|
-
return `
|
|
10
|
+
return `Permits for "${this.key}" were lost (TTL expired or taken by another holder)`;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*/
|
|
17
|
-
export class MutexBackingError extends Data.TaggedError("MutexBackingError")<{
|
|
18
|
-
readonly operation: string;
|
|
19
|
-
readonly cause: unknown;
|
|
20
|
-
}> {
|
|
21
|
-
get message() {
|
|
22
|
-
return `Backing store error during "${this.operation}": ${this.cause}`;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Internal error: lock not yet acquired (used for retry logic)
|
|
15
|
+
* Internal error: permits not yet acquired (used for retry logic)
|
|
28
16
|
* @internal
|
|
29
17
|
*/
|
|
30
18
|
export class NotYetAcquiredError extends Data.TaggedError(
|