@beignet/provider-locks-redis 0.0.14
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/package.json +106 -0
- package/src/index.ts +508 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taylor Bryant
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @beignet/provider-locks-redis
|
|
2
|
+
|
|
3
|
+
Redis-backed `LocksPort` provider for Beignet applications.
|
|
4
|
+
|
|
5
|
+
The provider installs `ctx.ports.locks` using `ioredis` and Redis lease
|
|
6
|
+
semantics:
|
|
7
|
+
|
|
8
|
+
- acquire the lease and fencing token atomically with Redis Lua
|
|
9
|
+
- renew only when the stored owner token matches
|
|
10
|
+
- release only when the stored owner token matches
|
|
11
|
+
- optional fencing tokens from Redis `INCR` inside the acquire script
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @beignet/provider-locks-redis ioredis
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Register
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// server/providers.ts
|
|
23
|
+
import { createRedisLocksProvider } from "@beignet/provider-locks-redis";
|
|
24
|
+
|
|
25
|
+
export const providers = [
|
|
26
|
+
createRedisLocksProvider({
|
|
27
|
+
prefix: "my-app:locks",
|
|
28
|
+
}),
|
|
29
|
+
];
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Set `REDIS_LOCKS_URL` for the default env-backed provider. You can also pass an
|
|
33
|
+
existing Redis-compatible client to `createRedisLocksProvider({ client })`.
|
|
34
|
+
Optional env vars:
|
|
35
|
+
|
|
36
|
+
- `REDIS_LOCKS_DB`
|
|
37
|
+
- `REDIS_LOCKS_PREFIX`
|
|
38
|
+
- `REDIS_LOCKS_CONNECT_TIMEOUT_MS`
|
|
39
|
+
- `REDIS_LOCKS_MAX_RETRIES_PER_REQUEST`
|
|
40
|
+
- `REDIS_LOCKS_CONNECT_MAX_ATTEMPTS`
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
const result = await ctx.ports.locks.acquire("schedule:daily-report", {
|
|
46
|
+
ttlMs: 60_000,
|
|
47
|
+
waitMs: 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!result.acquired) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await runDailyReport(ctx);
|
|
54
|
+
} finally {
|
|
55
|
+
await result.lease.release();
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or use `withLease(...)`:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
await ctx.ports.locks.withLease(
|
|
63
|
+
"outbox:drain",
|
|
64
|
+
{ ttlMs: 30_000, waitMs: 5_000 },
|
|
65
|
+
async ({ lease }) => {
|
|
66
|
+
await drainOutbox(ctx, { fencingToken: lease.fencingToken });
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### `createRedisLocks(options)`
|
|
74
|
+
|
|
75
|
+
Creates a `LocksPort` from a Redis-compatible client. Use this for tests or
|
|
76
|
+
custom provider composition.
|
|
77
|
+
|
|
78
|
+
### `createRedisLocksProvider(options)`
|
|
79
|
+
|
|
80
|
+
Creates a Beignet lifecycle provider that contributes:
|
|
81
|
+
|
|
82
|
+
- `ctx.ports.locks`, the standard Beignet `LocksPort`
|
|
83
|
+
- `ctx.ports.redisLocks`, an escape hatch with the raw Redis client and prefix
|
|
84
|
+
|
|
85
|
+
### `redisLocksProvider`
|
|
86
|
+
|
|
87
|
+
Ready-to-register provider using `REDIS_LOCKS_*` environment variables.
|
|
88
|
+
|
|
89
|
+
## Correctness note
|
|
90
|
+
|
|
91
|
+
Locks coordinate work; they are not a substitute for durable correctness. Use
|
|
92
|
+
database unique constraints, transactions, idempotency keys, and outbox claims
|
|
93
|
+
as the source of truth for business invariants. Use leases to prevent duplicate
|
|
94
|
+
scheduler runs, singleton workers, cache stampedes, and short critical sections.
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@beignet/provider-locks-redis",
|
|
3
|
+
"version": "0.0.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Redis-backed lock and lease provider for Beignet",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.test-d.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"clean": "rm -rf dist coverage .turbo",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"test:coverage": "bun test --coverage",
|
|
29
|
+
"lint": "biome check ."
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"beignet",
|
|
33
|
+
"redis",
|
|
34
|
+
"locks",
|
|
35
|
+
"leases",
|
|
36
|
+
"provider",
|
|
37
|
+
"ports"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/taylorbryant/beignet.git",
|
|
43
|
+
"directory": "packages/provider-locks-redis"
|
|
44
|
+
},
|
|
45
|
+
"author": "Taylor Bryant",
|
|
46
|
+
"homepage": "https://github.com/taylorbryant/beignet#readme",
|
|
47
|
+
"bugs": "https://github.com/taylorbryant/beignet/issues",
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=18.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@beignet/core": ">=0.0.3 <1.0.0",
|
|
57
|
+
"ioredis": "^5.0.0"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"zod": "^4.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@beignet/core": "*",
|
|
64
|
+
"@beignet/devtools": "*",
|
|
65
|
+
"@types/bun": "^1.3.13",
|
|
66
|
+
"@types/node": "^20.10.0",
|
|
67
|
+
"ioredis": "^5.0.0",
|
|
68
|
+
"typescript": "^5.3.0"
|
|
69
|
+
},
|
|
70
|
+
"beignet": {
|
|
71
|
+
"provider": {
|
|
72
|
+
"displayName": "Redis locks provider",
|
|
73
|
+
"ports": [
|
|
74
|
+
"locks",
|
|
75
|
+
"redisLocks"
|
|
76
|
+
],
|
|
77
|
+
"appPorts": [
|
|
78
|
+
{
|
|
79
|
+
"name": "locks",
|
|
80
|
+
"type": "LocksPort"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"env": [
|
|
84
|
+
"REDIS_LOCKS_URL",
|
|
85
|
+
"REDIS_LOCKS_DB",
|
|
86
|
+
"REDIS_LOCKS_PREFIX",
|
|
87
|
+
"REDIS_LOCKS_CONNECT_TIMEOUT_MS",
|
|
88
|
+
"REDIS_LOCKS_MAX_RETRIES_PER_REQUEST",
|
|
89
|
+
"REDIS_LOCKS_CONNECT_MAX_ATTEMPTS"
|
|
90
|
+
],
|
|
91
|
+
"requiredEnv": [
|
|
92
|
+
"REDIS_LOCKS_URL"
|
|
93
|
+
],
|
|
94
|
+
"watchers": [
|
|
95
|
+
"locks"
|
|
96
|
+
],
|
|
97
|
+
"registration": {
|
|
98
|
+
"required": true,
|
|
99
|
+
"tokens": [
|
|
100
|
+
"redisLocksProvider",
|
|
101
|
+
"createRedisLocksProvider"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @beignet/provider-locks-redis
|
|
3
|
+
*
|
|
4
|
+
* Redis-backed locks provider for Beignet.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
LeaseAcquireOptions,
|
|
9
|
+
LeaseHandle,
|
|
10
|
+
LeaseRenewOptions,
|
|
11
|
+
LocksPort,
|
|
12
|
+
} from "@beignet/core/locks";
|
|
13
|
+
import { LeaseOptionsError } from "@beignet/core/locks";
|
|
14
|
+
import {
|
|
15
|
+
createProvider,
|
|
16
|
+
createProviderInstrumentation,
|
|
17
|
+
type ProviderInstrumentationTarget,
|
|
18
|
+
} from "@beignet/core/providers";
|
|
19
|
+
import { Redis } from "ioredis";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
|
|
23
|
+
const DEFAULT_MAX_RETRIES_PER_REQUEST = 2;
|
|
24
|
+
const DEFAULT_CONNECT_MAX_ATTEMPTS = 3;
|
|
25
|
+
const DEFAULT_PREFIX = "beignet:locks";
|
|
26
|
+
const DEFAULT_RETRY_DELAY_MS = 50;
|
|
27
|
+
const RECONNECT_BACKOFF_CAP_MS = 2000;
|
|
28
|
+
|
|
29
|
+
const ACQUIRE_SCRIPT = `
|
|
30
|
+
if redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") then
|
|
31
|
+
return redis.call("incr", KEYS[2])
|
|
32
|
+
end
|
|
33
|
+
return nil
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const RELEASE_SCRIPT = `
|
|
37
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
38
|
+
return redis.call("del", KEYS[1])
|
|
39
|
+
end
|
|
40
|
+
return 0
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const RENEW_SCRIPT = `
|
|
44
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
45
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
46
|
+
end
|
|
47
|
+
return 0
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const NumberString = z
|
|
51
|
+
.string()
|
|
52
|
+
.regex(/^\d+$/, "Expected a non-negative integer string")
|
|
53
|
+
.transform((value) => Number.parseInt(value, 10));
|
|
54
|
+
|
|
55
|
+
const RedisLocksConfigSchema = z.object({
|
|
56
|
+
URL: z.string().url(),
|
|
57
|
+
DB: NumberString.optional(),
|
|
58
|
+
PREFIX: z.string().default(DEFAULT_PREFIX),
|
|
59
|
+
CONNECT_TIMEOUT_MS: NumberString.default(DEFAULT_CONNECT_TIMEOUT_MS),
|
|
60
|
+
MAX_RETRIES_PER_REQUEST: NumberString.default(
|
|
61
|
+
DEFAULT_MAX_RETRIES_PER_REQUEST,
|
|
62
|
+
),
|
|
63
|
+
CONNECT_MAX_ATTEMPTS: NumberString.default(DEFAULT_CONNECT_MAX_ATTEMPTS),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type RedisLocksConfig = z.infer<typeof RedisLocksConfigSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Redis client surface used by the locks adapter.
|
|
70
|
+
*/
|
|
71
|
+
export interface RedisLocksClient {
|
|
72
|
+
eval(
|
|
73
|
+
script: string,
|
|
74
|
+
numberOfKeys: number,
|
|
75
|
+
...args: unknown[]
|
|
76
|
+
): Promise<unknown>;
|
|
77
|
+
del(key: string): Promise<number>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Escape hatch for apps that need the raw Redis client.
|
|
82
|
+
*/
|
|
83
|
+
export interface RedisLocksEscapeHatch {
|
|
84
|
+
client: RedisLocksClient;
|
|
85
|
+
prefix: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Ports contributed by the Redis locks provider.
|
|
90
|
+
*/
|
|
91
|
+
export interface RedisLocksProviderPorts {
|
|
92
|
+
locks: LocksPort;
|
|
93
|
+
redisLocks: RedisLocksEscapeHatch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Options for the direct Redis locks factory.
|
|
98
|
+
*/
|
|
99
|
+
export interface CreateRedisLocksOptions {
|
|
100
|
+
client: RedisLocksClient;
|
|
101
|
+
prefix?: string;
|
|
102
|
+
instrumentation?: ProviderInstrumentationTarget;
|
|
103
|
+
createOwnerToken?: () => string;
|
|
104
|
+
sleep?: (ms: number) => Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Options for the Redis locks lifecycle provider.
|
|
109
|
+
*/
|
|
110
|
+
export interface CreateRedisLocksProviderOptions {
|
|
111
|
+
client?: RedisLocksClient;
|
|
112
|
+
prefix?: string;
|
|
113
|
+
connectTimeoutMs?: number;
|
|
114
|
+
maxRetriesPerRequest?: number;
|
|
115
|
+
retryStrategy?: (times: number) => number | null;
|
|
116
|
+
db?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a Redis-backed locks port.
|
|
121
|
+
*/
|
|
122
|
+
export function createRedisLocks(options: CreateRedisLocksOptions): LocksPort {
|
|
123
|
+
const client = options.client;
|
|
124
|
+
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
125
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
126
|
+
const createOwnerToken = options.createOwnerToken ?? createRandomToken;
|
|
127
|
+
const instrumentation = createProviderInstrumentation(
|
|
128
|
+
options.instrumentation,
|
|
129
|
+
{
|
|
130
|
+
providerName: "locks-redis",
|
|
131
|
+
watcher: "locks",
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const fullKey = (key: string) => (prefix ? `${prefix}:${key}` : key);
|
|
136
|
+
const fencingKey = (key: string) => `${fullKey(key)}:fence`;
|
|
137
|
+
|
|
138
|
+
const locks: LocksPort = {
|
|
139
|
+
async acquire(key, acquireOptions) {
|
|
140
|
+
validateAcquireOptions(key, acquireOptions);
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
const waitMs = acquireOptions.waitMs ?? 0;
|
|
143
|
+
const retryDelayMs =
|
|
144
|
+
acquireOptions.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
145
|
+
const deadline = Date.now() + waitMs;
|
|
146
|
+
const ownerToken = acquireOptions.ownerToken ?? createOwnerToken();
|
|
147
|
+
|
|
148
|
+
while (true) {
|
|
149
|
+
const fencingToken = await client.eval(
|
|
150
|
+
ACQUIRE_SCRIPT,
|
|
151
|
+
2,
|
|
152
|
+
fullKey(key),
|
|
153
|
+
fencingKey(key),
|
|
154
|
+
ownerToken,
|
|
155
|
+
acquireOptions.ttlMs,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (fencingToken !== null && fencingToken !== undefined) {
|
|
159
|
+
const lease = createRedisLease({
|
|
160
|
+
client,
|
|
161
|
+
key,
|
|
162
|
+
redisKey: fullKey(key),
|
|
163
|
+
ownerToken,
|
|
164
|
+
ttlMs: acquireOptions.ttlMs,
|
|
165
|
+
fencingToken: Number(fencingToken),
|
|
166
|
+
});
|
|
167
|
+
recordAcquire({
|
|
168
|
+
instrumentation,
|
|
169
|
+
key,
|
|
170
|
+
acquired: true,
|
|
171
|
+
ttlMs: acquireOptions.ttlMs,
|
|
172
|
+
waitMs,
|
|
173
|
+
durationMs: Date.now() - startedAt,
|
|
174
|
+
metadata: acquireOptions.metadata,
|
|
175
|
+
});
|
|
176
|
+
return { acquired: true, lease };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (waitMs <= 0) {
|
|
180
|
+
recordAcquire({
|
|
181
|
+
instrumentation,
|
|
182
|
+
key,
|
|
183
|
+
acquired: false,
|
|
184
|
+
reason: "unavailable",
|
|
185
|
+
ttlMs: acquireOptions.ttlMs,
|
|
186
|
+
waitMs,
|
|
187
|
+
durationMs: Date.now() - startedAt,
|
|
188
|
+
metadata: acquireOptions.metadata,
|
|
189
|
+
});
|
|
190
|
+
return { acquired: false, reason: "unavailable" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const remainingMs = deadline - Date.now();
|
|
194
|
+
if (remainingMs <= 0) {
|
|
195
|
+
recordAcquire({
|
|
196
|
+
instrumentation,
|
|
197
|
+
key,
|
|
198
|
+
acquired: false,
|
|
199
|
+
reason: "timeout",
|
|
200
|
+
ttlMs: acquireOptions.ttlMs,
|
|
201
|
+
waitMs,
|
|
202
|
+
durationMs: Date.now() - startedAt,
|
|
203
|
+
metadata: acquireOptions.metadata,
|
|
204
|
+
});
|
|
205
|
+
return { acquired: false, reason: "timeout" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await sleep(Math.min(retryDelayMs, remainingMs));
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async withLease(key, acquireOptions, fn) {
|
|
212
|
+
const result = await locks.acquire(key, acquireOptions);
|
|
213
|
+
if (!result.acquired) return undefined;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
return await fn({ lease: result.lease });
|
|
217
|
+
} finally {
|
|
218
|
+
await result.lease.release();
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
restore(key, ownerToken) {
|
|
222
|
+
if (!key) throw new LeaseOptionsError("Lease key is required.");
|
|
223
|
+
if (!ownerToken) {
|
|
224
|
+
throw new LeaseOptionsError("Lease owner token is required.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return createRedisLease({
|
|
228
|
+
client,
|
|
229
|
+
key,
|
|
230
|
+
redisKey: fullKey(key),
|
|
231
|
+
ownerToken,
|
|
232
|
+
ttlMs: 1,
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
async forceRelease(key) {
|
|
236
|
+
if (!key) throw new LeaseOptionsError("Lease key is required.");
|
|
237
|
+
const released = (await client.del(fullKey(key))) > 0;
|
|
238
|
+
instrumentation.custom({
|
|
239
|
+
name: "locks.forceRelease",
|
|
240
|
+
label: "Lock force release",
|
|
241
|
+
summary: released ? "Lease force released" : "Lease not found",
|
|
242
|
+
details: { key, prefix, released },
|
|
243
|
+
});
|
|
244
|
+
return released;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return locks;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create an env-backed Redis locks provider.
|
|
253
|
+
*/
|
|
254
|
+
export function createRedisLocksProvider(
|
|
255
|
+
options: CreateRedisLocksProviderOptions = {},
|
|
256
|
+
) {
|
|
257
|
+
const ConfigSchema = RedisLocksConfigSchema.extend({
|
|
258
|
+
DB:
|
|
259
|
+
options.db !== undefined
|
|
260
|
+
? NumberString.default(options.db)
|
|
261
|
+
: RedisLocksConfigSchema.shape.DB,
|
|
262
|
+
PREFIX:
|
|
263
|
+
options.prefix !== undefined
|
|
264
|
+
? z.string().default(options.prefix)
|
|
265
|
+
: RedisLocksConfigSchema.shape.PREFIX,
|
|
266
|
+
CONNECT_TIMEOUT_MS:
|
|
267
|
+
options.connectTimeoutMs !== undefined
|
|
268
|
+
? NumberString.default(options.connectTimeoutMs)
|
|
269
|
+
: RedisLocksConfigSchema.shape.CONNECT_TIMEOUT_MS,
|
|
270
|
+
MAX_RETRIES_PER_REQUEST:
|
|
271
|
+
options.maxRetriesPerRequest !== undefined
|
|
272
|
+
? NumberString.default(options.maxRetriesPerRequest)
|
|
273
|
+
: RedisLocksConfigSchema.shape.MAX_RETRIES_PER_REQUEST,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const providerMetadata = {
|
|
277
|
+
packageName: "@beignet/provider-locks-redis",
|
|
278
|
+
ports: ["locks", "redisLocks"],
|
|
279
|
+
watchers: ["locks"],
|
|
280
|
+
} as const;
|
|
281
|
+
|
|
282
|
+
if (options.client) {
|
|
283
|
+
const client = options.client;
|
|
284
|
+
|
|
285
|
+
return createProvider({
|
|
286
|
+
name: "locks-redis",
|
|
287
|
+
metadata: providerMetadata,
|
|
288
|
+
async setup({ ports }) {
|
|
289
|
+
const configuredPrefix = options.prefix ?? DEFAULT_PREFIX;
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
ports: {
|
|
293
|
+
locks: createRedisLocks({
|
|
294
|
+
client,
|
|
295
|
+
prefix: configuredPrefix,
|
|
296
|
+
instrumentation: ports,
|
|
297
|
+
}),
|
|
298
|
+
redisLocks: {
|
|
299
|
+
client,
|
|
300
|
+
prefix: configuredPrefix,
|
|
301
|
+
},
|
|
302
|
+
} satisfies RedisLocksProviderPorts,
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return createProvider({
|
|
309
|
+
name: "locks-redis",
|
|
310
|
+
metadata: providerMetadata,
|
|
311
|
+
config: {
|
|
312
|
+
schema: ConfigSchema,
|
|
313
|
+
envPrefix: "REDIS_LOCKS_",
|
|
314
|
+
},
|
|
315
|
+
async setup({ ports, config }) {
|
|
316
|
+
const configuredPrefix =
|
|
317
|
+
config?.PREFIX ?? options.prefix ?? DEFAULT_PREFIX;
|
|
318
|
+
|
|
319
|
+
if (!config) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
"[redisLocksProvider] Missing Redis locks config. " +
|
|
322
|
+
"Please set REDIS_LOCKS_URL environment variable.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const client = await connectRedisClient(config, options);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
ports: {
|
|
330
|
+
locks: createRedisLocks({
|
|
331
|
+
client,
|
|
332
|
+
prefix: configuredPrefix,
|
|
333
|
+
instrumentation: ports,
|
|
334
|
+
}),
|
|
335
|
+
redisLocks: {
|
|
336
|
+
client,
|
|
337
|
+
prefix: configuredPrefix,
|
|
338
|
+
},
|
|
339
|
+
} satisfies RedisLocksProviderPorts,
|
|
340
|
+
async stop() {
|
|
341
|
+
try {
|
|
342
|
+
await client.quit();
|
|
343
|
+
} catch {
|
|
344
|
+
// Ignore shutdown errors.
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Default env-backed Redis locks provider.
|
|
354
|
+
*/
|
|
355
|
+
export const redisLocksProvider = createRedisLocksProvider();
|
|
356
|
+
|
|
357
|
+
function createRedisLease(args: {
|
|
358
|
+
client: RedisLocksClient;
|
|
359
|
+
key: string;
|
|
360
|
+
redisKey: string;
|
|
361
|
+
ownerToken: string;
|
|
362
|
+
ttlMs: number;
|
|
363
|
+
fencingToken?: number;
|
|
364
|
+
}): LeaseHandle {
|
|
365
|
+
const lease: LeaseHandle = {
|
|
366
|
+
key: args.key,
|
|
367
|
+
ownerToken: args.ownerToken,
|
|
368
|
+
expiresAt: new Date(Date.now() + args.ttlMs),
|
|
369
|
+
fencingToken: args.fencingToken,
|
|
370
|
+
async renew(options?: LeaseRenewOptions) {
|
|
371
|
+
const ttlMs = options?.ttlMs ?? args.ttlMs;
|
|
372
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
373
|
+
throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const renewed = Number(
|
|
377
|
+
await args.client.eval(
|
|
378
|
+
RENEW_SCRIPT,
|
|
379
|
+
1,
|
|
380
|
+
args.redisKey,
|
|
381
|
+
args.ownerToken,
|
|
382
|
+
ttlMs,
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
if (renewed !== 1) return false;
|
|
386
|
+
args.ttlMs = ttlMs;
|
|
387
|
+
lease.expiresAt = new Date(Date.now() + ttlMs);
|
|
388
|
+
return true;
|
|
389
|
+
},
|
|
390
|
+
async release() {
|
|
391
|
+
const released = Number(
|
|
392
|
+
await args.client.eval(
|
|
393
|
+
RELEASE_SCRIPT,
|
|
394
|
+
1,
|
|
395
|
+
args.redisKey,
|
|
396
|
+
args.ownerToken,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
return released === 1;
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return lease;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function connectRedisClient(
|
|
407
|
+
config: RedisLocksConfig,
|
|
408
|
+
options: CreateRedisLocksProviderOptions,
|
|
409
|
+
): Promise<Redis> {
|
|
410
|
+
const connectMaxAttempts =
|
|
411
|
+
config.CONNECT_MAX_ATTEMPTS ?? DEFAULT_CONNECT_MAX_ATTEMPTS;
|
|
412
|
+
let connectedOnce = false;
|
|
413
|
+
|
|
414
|
+
const defaultRetryStrategy = (attempt: number): number | null => {
|
|
415
|
+
if (!connectedOnce) {
|
|
416
|
+
if (attempt >= connectMaxAttempts) return null;
|
|
417
|
+
return reconnectBackoff(attempt);
|
|
418
|
+
}
|
|
419
|
+
return reconnectBackoff(attempt);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const client = new Redis(config.URL, {
|
|
423
|
+
db: config.DB ?? options.db ?? 0,
|
|
424
|
+
lazyConnect: true,
|
|
425
|
+
connectTimeout:
|
|
426
|
+
config.CONNECT_TIMEOUT_MS ??
|
|
427
|
+
options.connectTimeoutMs ??
|
|
428
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
429
|
+
maxRetriesPerRequest:
|
|
430
|
+
config.MAX_RETRIES_PER_REQUEST ??
|
|
431
|
+
options.maxRetriesPerRequest ??
|
|
432
|
+
DEFAULT_MAX_RETRIES_PER_REQUEST,
|
|
433
|
+
retryStrategy: options.retryStrategy ?? defaultRetryStrategy,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
await client.connect();
|
|
438
|
+
connectedOnce = true;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
client.disconnect();
|
|
441
|
+
throw new Error(
|
|
442
|
+
`[redisLocksProvider] Failed to connect to Redis at ${config.URL}: ${errorMessage(
|
|
443
|
+
error,
|
|
444
|
+
)}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return client;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function recordAcquire(args: {
|
|
452
|
+
instrumentation: ReturnType<typeof createProviderInstrumentation>;
|
|
453
|
+
key: string;
|
|
454
|
+
acquired: boolean;
|
|
455
|
+
reason?: "unavailable" | "timeout";
|
|
456
|
+
ttlMs: number;
|
|
457
|
+
waitMs: number;
|
|
458
|
+
durationMs: number;
|
|
459
|
+
metadata?: LeaseAcquireOptions["metadata"];
|
|
460
|
+
}) {
|
|
461
|
+
args.instrumentation.custom({
|
|
462
|
+
name: "locks.acquire",
|
|
463
|
+
label: "Lock acquire",
|
|
464
|
+
summary: args.acquired ? "Lease acquired" : "Lease not acquired",
|
|
465
|
+
details: {
|
|
466
|
+
key: args.key,
|
|
467
|
+
acquired: args.acquired,
|
|
468
|
+
reason: args.reason,
|
|
469
|
+
ttlMs: args.ttlMs,
|
|
470
|
+
waitMs: args.waitMs,
|
|
471
|
+
durationMs: args.durationMs,
|
|
472
|
+
metadata: args.metadata,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function validateAcquireOptions(key: string, options: LeaseAcquireOptions) {
|
|
478
|
+
if (!key) throw new LeaseOptionsError("Lease key is required.");
|
|
479
|
+
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
|
|
480
|
+
throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
|
|
481
|
+
}
|
|
482
|
+
if (options.waitMs !== undefined && options.waitMs < 0) {
|
|
483
|
+
throw new LeaseOptionsError("Lease waitMs must be zero or greater.");
|
|
484
|
+
}
|
|
485
|
+
if (options.retryDelayMs !== undefined && options.retryDelayMs <= 0) {
|
|
486
|
+
throw new LeaseOptionsError("Lease retryDelayMs must be positive.");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function reconnectBackoff(attempt: number): number {
|
|
491
|
+
return Math.min(2 ** attempt * 100, RECONNECT_BACKOFF_CAP_MS);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function createRandomToken(): string {
|
|
495
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
496
|
+
return crypto.randomUUID();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
503
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function errorMessage(error: unknown): string {
|
|
507
|
+
return error instanceof Error ? error.message : String(error);
|
|
508
|
+
}
|