@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 ADDED
@@ -0,0 +1,13 @@
1
+ # @beignet/provider-locks-redis
2
+
3
+ ## 0.0.14
4
+
5
+ ### Patch Changes
6
+
7
+ - bcd3864: Add lease-backed lock primitives and a Redis-backed locks provider.
8
+
9
+ ## 0.0.13
10
+
11
+ ### Patch Changes
12
+
13
+ - Initial Redis-backed locks provider.
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
+ }