effect-distributed-lock 0.0.1 → 0.0.2
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/LICENSE +22 -0
- package/README.md +151 -6
- package/package.json +10 -1
- package/src/RedisBacking.ts +1 -11
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ethan Niser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
CHANGED
|
@@ -1,15 +1,160 @@
|
|
|
1
1
|
# effect-distributed-lock
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A distributed mutex library for [Effect](https://effect.website/) with pluggable backends.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Scope-based resource management** — locks are automatically released when the scope closes
|
|
8
|
+
- **Automatic TTL refresh** — keeps locks alive while held, prevents deadlocks if holder crashes
|
|
9
|
+
- **Pluggable backends** — ships with Redis, easy to implement others (etcd, DynamoDB, etc.)
|
|
10
|
+
- **Configurable retry & timeout** — control polling interval, acquire timeout, and TTL
|
|
11
|
+
- **Type-safe errors** — tagged errors for precise error handling
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
4
14
|
|
|
5
15
|
```bash
|
|
6
|
-
|
|
16
|
+
npm install effect-distributed-lock effect
|
|
17
|
+
# or
|
|
18
|
+
bun add effect-distributed-lock effect
|
|
19
|
+
|
|
20
|
+
# For Redis backing (optional)
|
|
21
|
+
npm install ioredis
|
|
7
22
|
```
|
|
8
23
|
|
|
9
|
-
|
|
24
|
+
## Quick Start
|
|
10
25
|
|
|
11
|
-
```
|
|
12
|
-
|
|
26
|
+
```typescript
|
|
27
|
+
import { Effect } from "effect";
|
|
28
|
+
import Redis from "ioredis";
|
|
29
|
+
import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
|
|
30
|
+
|
|
31
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
32
|
+
const RedisLayer = RedisBacking.layer(redis);
|
|
33
|
+
|
|
34
|
+
const program = Effect.gen(function* () {
|
|
35
|
+
const mutex = yield* DistributedMutex.make("my-resource", {
|
|
36
|
+
ttl: "30 seconds",
|
|
37
|
+
acquireTimeout: "10 seconds",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Lock is held while effect runs, released automatically after
|
|
41
|
+
yield* mutex.withLock(
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
// Critical section - only one process can be here at a time
|
|
44
|
+
yield* doExclusiveWork();
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### Creating a Mutex
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const mutex = yield* DistributedMutex.make(key, config);
|
|
13
58
|
```
|
|
14
59
|
|
|
15
|
-
|
|
60
|
+
| Config Option | Type | Default | Description |
|
|
61
|
+
| ----------------- | ---------------- | ------------ | ------------------------------------------------ |
|
|
62
|
+
| `ttl` | `DurationInput` | `30 seconds` | Lock TTL (auto-releases if holder crashes) |
|
|
63
|
+
| `refreshInterval` | `DurationInput` | `ttl / 3` | How often to refresh TTL while holding |
|
|
64
|
+
| `retryInterval` | `DurationInput` | `100ms` | Polling interval when waiting to acquire |
|
|
65
|
+
| `acquireTimeout` | `DurationInput` | `∞` | Max time to wait for lock (fails if exceeded) |
|
|
66
|
+
|
|
67
|
+
### Using the Mutex
|
|
68
|
+
|
|
69
|
+
#### `withLock` — Acquire, run, release
|
|
70
|
+
|
|
71
|
+
The simplest and recommended way. Acquires the lock, runs your effect, and releases when done:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
yield* mutex.withLock(myEffect);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### `withLockIfAvailable` — Non-blocking acquire
|
|
78
|
+
|
|
79
|
+
Tries to acquire immediately without waiting. Returns `Option.some(result)` if successful, `Option.none()` if lock is held:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const result = yield* mutex.withLockIfAvailable(myEffect);
|
|
83
|
+
if (Option.isSome(result)) {
|
|
84
|
+
console.log("Got the lock!", result.value);
|
|
85
|
+
} else {
|
|
86
|
+
console.log("Lock was busy, skipping");
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### `acquire` / `tryAcquire` — Manual scope control
|
|
91
|
+
|
|
92
|
+
For advanced use cases where you need explicit control over the lock lifecycle:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
yield* Effect.scoped(
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
yield* mutex.acquire; // Lock held until scope closes
|
|
98
|
+
yield* doWork();
|
|
99
|
+
// Lock automatically released here
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### `isLocked` — Check lock status
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const locked = yield* mutex.isLocked;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Error Handling
|
|
111
|
+
|
|
112
|
+
All errors are tagged for precise handling with `Effect.catchTag`:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
yield* mutex.withLock(myEffect).pipe(
|
|
116
|
+
Effect.catchTag("AcquireTimeoutError", (e) =>
|
|
117
|
+
Effect.log(`Timed out acquiring lock: ${e.key}`)
|
|
118
|
+
),
|
|
119
|
+
Effect.catchTag("LockLostError", (e) =>
|
|
120
|
+
Effect.log(`Lock was lost while held: ${e.key}`)
|
|
121
|
+
),
|
|
122
|
+
Effect.catchTag("BackingError", (e) =>
|
|
123
|
+
Effect.log(`Redis error: ${e.message}`)
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Error | Description |
|
|
129
|
+
| --------------------- | ---------------------------------------------------- |
|
|
130
|
+
| `AcquireTimeoutError` | Failed to acquire lock within the timeout period |
|
|
131
|
+
| `LockLostError` | Lock TTL expired while we thought we held it |
|
|
132
|
+
| `BackingError` | Error from the backing store (Redis connection, etc) |
|
|
133
|
+
|
|
134
|
+
## Custom Backends
|
|
135
|
+
|
|
136
|
+
Implement the `DistributedMutexBacking` interface to use a different store:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { Layer } from "effect";
|
|
140
|
+
import { DistributedMutex } from "effect-distributed-lock";
|
|
141
|
+
|
|
142
|
+
const MyCustomBacking = Layer.succeed(DistributedMutex.DistributedMutexBacking, {
|
|
143
|
+
tryAcquire: (key, holderId, ttlMs) => /* ... */,
|
|
144
|
+
release: (key, holderId) => /* ... */,
|
|
145
|
+
refresh: (key, holderId, ttlMs) => /* ... */,
|
|
146
|
+
isLocked: (key) => /* ... */,
|
|
147
|
+
getHolder: (key) => /* ... */,
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## How It Works
|
|
152
|
+
|
|
153
|
+
1. **Acquire**: Atomically sets the lock key if not exists (Redis: `SET key value NX PX ttl`)
|
|
154
|
+
2. **Keepalive**: A background fiber refreshes the TTL periodically while the lock is held
|
|
155
|
+
3. **Release**: Atomically deletes the key only if we're still the holder (Lua script for atomicity)
|
|
156
|
+
4. **Crash safety**: If the holder crashes, the TTL expires and another process can acquire
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-distributed-lock",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "A distributed mutex library for Effect with pluggable backends",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/ethanniser/effect-distributed-lock.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/ethanniser/effect-distributed-lock",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ethanniser/effect-distributed-lock/issues"
|
|
13
|
+
},
|
|
5
14
|
"type": "module",
|
|
6
15
|
"scripts": {
|
|
7
16
|
"build": "tsc",
|
package/src/RedisBacking.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Layer, Option } from "effect";
|
|
2
|
-
import
|
|
2
|
+
import { Redis } from "ioredis";
|
|
3
3
|
import { DistributedMutexBacking } from "./DistributedMutex.js";
|
|
4
4
|
import { BackingError } from "./Errors.js";
|
|
5
5
|
|
|
@@ -162,16 +162,6 @@ export const layerFromUrl = (
|
|
|
162
162
|
Layer.scoped(
|
|
163
163
|
DistributedMutexBacking,
|
|
164
164
|
Effect.gen(function* () {
|
|
165
|
-
// Dynamic import to avoid requiring ioredis at module load time
|
|
166
|
-
const { default: Redis } = yield* Effect.tryPromise({
|
|
167
|
-
try: () => import("ioredis"),
|
|
168
|
-
catch: (cause) =>
|
|
169
|
-
new BackingError({
|
|
170
|
-
operation: "import",
|
|
171
|
-
cause: `Failed to import ioredis: ${cause}`,
|
|
172
|
-
}),
|
|
173
|
-
});
|
|
174
|
-
|
|
175
165
|
const redis = new Redis(url);
|
|
176
166
|
|
|
177
167
|
// Ensure cleanup on scope close
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```ts
|
|
8
|
-
* import { DistributedMutex,
|
|
8
|
+
* import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
|
|
9
9
|
* import { Effect } from "effect";
|
|
10
10
|
* import Redis from "ioredis";
|
|
11
11
|
*
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* });
|
|
29
29
|
*
|
|
30
30
|
* program.pipe(
|
|
31
|
-
* Effect.provide(
|
|
31
|
+
* Effect.provide(RedisBacking.layer(redis)),
|
|
32
32
|
* Effect.runPromise
|
|
33
33
|
* );
|
|
34
34
|
* ```
|
package/tsconfig.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// Environment setup & latest features
|
|
4
4
|
"lib": ["ESNext"],
|
|
5
5
|
"target": "ESNext",
|
|
6
|
-
"module": "
|
|
6
|
+
"module": "NodeNext",
|
|
7
7
|
"moduleDetection": "force",
|
|
8
8
|
"jsx": "react-jsx",
|
|
9
9
|
"allowJs": true,
|
|
10
10
|
|
|
11
11
|
// Bundler mode
|
|
12
|
-
"moduleResolution": "
|
|
12
|
+
"moduleResolution": "nodenext",
|
|
13
13
|
"verbatimModuleSyntax": true,
|
|
14
14
|
|
|
15
15
|
// Build output
|