ioredis-toolkit 0.0.1
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 +21 -0
- package/README.md +645 -0
- package/dist/cache.d.ts +298 -0
- package/dist/cache.js +606 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +958 -0
- package/dist/cluster-slot.d.ts +4 -0
- package/dist/cluster-slot.js +31 -0
- package/dist/cluster.d.ts +79 -0
- package/dist/cluster.js +156 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +63 -0
- package/dist/health.d.ts +39 -0
- package/dist/health.js +106 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +44 -0
- package/dist/lock.d.ts +215 -0
- package/dist/lock.js +385 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +40 -0
- package/dist/pubsub.d.ts +171 -0
- package/dist/pubsub.js +285 -0
- package/dist/ratelimiter.d.ts +162 -0
- package/dist/ratelimiter.js +289 -0
- package/dist/session/index.d.ts +23 -0
- package/dist/session/index.js +16 -0
- package/dist/session/revocation-store.d.ts +171 -0
- package/dist/session/revocation-store.js +310 -0
- package/dist/session/scripts/cleanup-index.lua +21 -0
- package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
- package/dist/session/scripts/conditional-update.lua +63 -0
- package/dist/session/scripts/create.lua +68 -0
- package/dist/session/scripts/delete-by-user.lua +29 -0
- package/dist/session/scripts/delete.lua +15 -0
- package/dist/session/scripts/enforce-limit.lua +38 -0
- package/dist/session/scripts/revoke.lua +61 -0
- package/dist/session/scripts/rotate-encrypted.lua +107 -0
- package/dist/session/scripts/rotate.lua +119 -0
- package/dist/session/scripts/touch-encrypted.lua +89 -0
- package/dist/session/scripts/touch.lua +72 -0
- package/dist/session/scripts/validate.lua +90 -0
- package/dist/session/session-circuit-breaker.d.ts +42 -0
- package/dist/session/session-circuit-breaker.js +129 -0
- package/dist/session/session-config.d.ts +335 -0
- package/dist/session/session-config.js +162 -0
- package/dist/session/session-cookie.d.ts +72 -0
- package/dist/session/session-cookie.js +101 -0
- package/dist/session/session-encryption.d.ts +87 -0
- package/dist/session/session-encryption.js +139 -0
- package/dist/session/session-errors.d.ts +85 -0
- package/dist/session/session-errors.js +145 -0
- package/dist/session/session-health.d.ts +38 -0
- package/dist/session/session-health.js +60 -0
- package/dist/session/session-keys.d.ts +51 -0
- package/dist/session/session-keys.js +113 -0
- package/dist/session/session-manager.d.ts +59 -0
- package/dist/session/session-manager.js +94 -0
- package/dist/session/session-metrics.d.ts +33 -0
- package/dist/session/session-metrics.js +112 -0
- package/dist/session/session-repository.d.ts +161 -0
- package/dist/session/session-repository.js +683 -0
- package/dist/session/session-scripts.d.ts +36 -0
- package/dist/session/session-scripts.js +130 -0
- package/dist/session/session-serializer.d.ts +42 -0
- package/dist/session/session-serializer.js +248 -0
- package/dist/session/session-service.d.ts +104 -0
- package/dist/session/session-service.js +611 -0
- package/dist/session/session-token.d.ts +38 -0
- package/dist/session/session-token.js +86 -0
- package/dist/session/session-types.d.ts +253 -0
- package/dist/session/session-types.js +16 -0
- package/dist/types.d.ts +782 -0
- package/dist/types.js +140 -0
- package/package.json +97 -0
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { RedisClientWrapper } from './client.js';
|
|
2
|
+
import { LoggerLike } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Information about a distributed lock.
|
|
5
|
+
*/
|
|
6
|
+
export interface LockInfo {
|
|
7
|
+
/** Whether the lock is currently held. */
|
|
8
|
+
locked: boolean;
|
|
9
|
+
/** Remaining TTL in seconds (when held and TTL set). */
|
|
10
|
+
ttl?: number;
|
|
11
|
+
/** Unique owner id of the lock. */
|
|
12
|
+
lockId?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Options for the distributed lock.
|
|
16
|
+
*/
|
|
17
|
+
export interface DistributedLockOptions {
|
|
18
|
+
/** Lock TTL in milliseconds. Default: `30000`. */
|
|
19
|
+
ttl?: number;
|
|
20
|
+
/** Number of acquisition attempts. Default: `3`. */
|
|
21
|
+
retryCount?: number;
|
|
22
|
+
/** Base delay between retries in ms (grows exponentially). Default: `200`. */
|
|
23
|
+
retryDelay?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Distributed mutual-exclusion lock backed by Redis.
|
|
27
|
+
*
|
|
28
|
+
* Works in standalone, sentinel and cluster modes. Acquisition uses atomic
|
|
29
|
+
* `SET ... PX NX`; release and extension use Lua scripts so only the lock owner
|
|
30
|
+
* can release or extend. `withLock` auto-extends the lock at half TTL while the
|
|
31
|
+
* critical section runs and always releases afterwards.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const lock = new DistributedLock(client, { ttl: 30000, retryCount: 5 });
|
|
36
|
+
* const acquired = await lock.acquire('order:42');
|
|
37
|
+
* if (acquired) {
|
|
38
|
+
* try {
|
|
39
|
+
* // critical section
|
|
40
|
+
* } finally {
|
|
41
|
+
* await lock.release('order:42');
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare class DistributedLock {
|
|
47
|
+
private client;
|
|
48
|
+
private logger;
|
|
49
|
+
private defaultTTL;
|
|
50
|
+
private defaultRetryCount;
|
|
51
|
+
private defaultRetryDelay;
|
|
52
|
+
/**
|
|
53
|
+
* Creates a distributed lock bound to a Redis client.
|
|
54
|
+
*
|
|
55
|
+
* @param client - The underlying {@link RedisClientWrapper}.
|
|
56
|
+
* @param logger - Optional pino-compatible logger; defaults to `console`.
|
|
57
|
+
* @param options - Defaults for `ttl` (ms), `retryCount` and `retryDelay`.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const lock = new DistributedLock(client, { ttl: 10000, retryCount: 3 });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
constructor(client: RedisClientWrapper, logger?: LoggerLike, options?: Partial<DistributedLockOptions>);
|
|
65
|
+
private getLockKey;
|
|
66
|
+
private generateLockId;
|
|
67
|
+
private executeWithRetry;
|
|
68
|
+
/**
|
|
69
|
+
* Attempts to acquire the lock for a key.
|
|
70
|
+
*
|
|
71
|
+
* Uses atomic `SET lock:<key> <id> PX <ttl> NX` with exponential backoff
|
|
72
|
+
* retries. Locks expire automatically after `ttl` ms, so a crashed holder
|
|
73
|
+
* never blocks others forever.
|
|
74
|
+
*
|
|
75
|
+
* @param key - The resource to lock, e.g. `'order:42'` (stored as `lock:order:42`).
|
|
76
|
+
* @param ttl - Lock TTL in milliseconds (default: `30000`).
|
|
77
|
+
* @returns `true` when the lock was acquired.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const acquired = await lock.acquire('order:42', 10000);
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
acquire(key: string, ttl?: number): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Releases the lock, but only if this process still owns it.
|
|
87
|
+
*
|
|
88
|
+
* Uses an atomic Lua check-and-delete so a lock whose TTL expired (and was
|
|
89
|
+
* re-acquired by someone else) is never removed by the old owner.
|
|
90
|
+
*
|
|
91
|
+
* @param key - The locked resource.
|
|
92
|
+
* @returns `true` if the lock was released, `false` if not owned or missing.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* await lock.release('order:42');
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
release(key: string): Promise<boolean>;
|
|
100
|
+
/**
|
|
101
|
+
* Force-releases a lock without checking ownership.
|
|
102
|
+
*
|
|
103
|
+
* Use with care: only for emergency cleanup or when the holder is known to
|
|
104
|
+
* be gone. This is what `withLock` falls back to when a normal release fails.
|
|
105
|
+
*
|
|
106
|
+
* @param key - The locked resource.
|
|
107
|
+
* @returns `true` if a lock existed and was deleted.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* await lock.releaseForce('order:42');
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
releaseForce(key: string): Promise<boolean>;
|
|
115
|
+
/**
|
|
116
|
+
* Extends the TTL of a lock this process still owns.
|
|
117
|
+
*
|
|
118
|
+
* Uses an atomic Lua script so a re-acquired lock is never extended by the
|
|
119
|
+
* old owner.
|
|
120
|
+
*
|
|
121
|
+
* @param key - The locked resource.
|
|
122
|
+
* @param ttl - New TTL in milliseconds (default: `30000`).
|
|
123
|
+
* @returns `true` if the lock was extended.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* const extended = await lock.extend('order:42', 30000);
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
extend(key: string, ttl?: number): Promise<boolean>;
|
|
131
|
+
/**
|
|
132
|
+
* Runs a critical section while holding a lock.
|
|
133
|
+
*
|
|
134
|
+
* Acquires the lock (with retries), auto-extends it at half TTL while `fn`
|
|
135
|
+
* runs, detects a lost lock, and always releases afterwards (force-releasing
|
|
136
|
+
* if a normal release fails).
|
|
137
|
+
*
|
|
138
|
+
* @param key - The resource to lock.
|
|
139
|
+
* @param fn - The critical section to run exclusively.
|
|
140
|
+
* @param options - Per-call `ttl` (ms), `retryCount`, `retryDelay`.
|
|
141
|
+
* @returns The return value of `fn`.
|
|
142
|
+
* @throws {@link RedisError} with code `LOCK_ACQUISITION_FAILED` when the lock
|
|
143
|
+
* cannot be acquired, or `LOCK_LOST` when the lock expired mid-execution.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* const result = await lock.withLock('inventory:sku-1', async () => {
|
|
148
|
+
* return await updateStock();
|
|
149
|
+
* });
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
withLock<T>(key: string, fn: () => Promise<T>, options?: DistributedLockOptions): Promise<T>;
|
|
153
|
+
/**
|
|
154
|
+
* Checks whether a lock is currently held.
|
|
155
|
+
*
|
|
156
|
+
* @param key - The locked resource.
|
|
157
|
+
* @returns `true` if the lock exists (held by anyone).
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* const busy = await lock.isLocked('order:42');
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
isLocked(key: string): Promise<boolean>;
|
|
165
|
+
/**
|
|
166
|
+
* Returns details about a lock.
|
|
167
|
+
*
|
|
168
|
+
* @param key - The locked resource.
|
|
169
|
+
* @returns `{ locked: false }` when not held, otherwise `{ locked: true, ttl, lockId }`.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* const info = await lock.getLockInfo('order:42');
|
|
174
|
+
* // { locked: true, ttl: 29, lockId: 'a1b2c3...' }
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
getLockInfo(key: string): Promise<LockInfo>;
|
|
178
|
+
/**
|
|
179
|
+
* Returns the owner id of a lock.
|
|
180
|
+
*
|
|
181
|
+
* @param key - The locked resource.
|
|
182
|
+
* @returns The lock id (random hex token), or `null` when not held.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* const owner = await lock.getLockOwner('order:42');
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
getLockOwner(key: string): Promise<string | null>;
|
|
190
|
+
/**
|
|
191
|
+
* Returns the remaining TTL of a lock in seconds.
|
|
192
|
+
*
|
|
193
|
+
* @param key - The locked resource.
|
|
194
|
+
* @returns Remaining seconds (`0` when not held or expired).
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* const remaining = await lock.getLockTTL('order:42');
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
getLockTTL(key: string): Promise<number>;
|
|
202
|
+
/**
|
|
203
|
+
* Deletes every lock key (`lock:*`) from Redis.
|
|
204
|
+
*
|
|
205
|
+
* Intended for tests and emergency recovery only.
|
|
206
|
+
*
|
|
207
|
+
* @returns The number of deleted locks.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* const removed = await lock.cleanupAll();
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
cleanupAll(): Promise<number>;
|
|
215
|
+
}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { RedisError } from './errors.js';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { defaultLogger } from './logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Distributed mutual-exclusion lock backed by Redis.
|
|
6
|
+
*
|
|
7
|
+
* Works in standalone, sentinel and cluster modes. Acquisition uses atomic
|
|
8
|
+
* `SET ... PX NX`; release and extension use Lua scripts so only the lock owner
|
|
9
|
+
* can release or extend. `withLock` auto-extends the lock at half TTL while the
|
|
10
|
+
* critical section runs and always releases afterwards.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const lock = new DistributedLock(client, { ttl: 30000, retryCount: 5 });
|
|
15
|
+
* const acquired = await lock.acquire('order:42');
|
|
16
|
+
* if (acquired) {
|
|
17
|
+
* try {
|
|
18
|
+
* // critical section
|
|
19
|
+
* } finally {
|
|
20
|
+
* await lock.release('order:42');
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class DistributedLock {
|
|
26
|
+
client;
|
|
27
|
+
logger;
|
|
28
|
+
defaultTTL;
|
|
29
|
+
defaultRetryCount;
|
|
30
|
+
defaultRetryDelay;
|
|
31
|
+
/**
|
|
32
|
+
* Creates a distributed lock bound to a Redis client.
|
|
33
|
+
*
|
|
34
|
+
* @param client - The underlying {@link RedisClientWrapper}.
|
|
35
|
+
* @param logger - Optional pino-compatible logger; defaults to `console`.
|
|
36
|
+
* @param options - Defaults for `ttl` (ms), `retryCount` and `retryDelay`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const lock = new DistributedLock(client, { ttl: 10000, retryCount: 3 });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
constructor(client, logger = defaultLogger, options = {}) {
|
|
44
|
+
this.client = client;
|
|
45
|
+
this.logger = logger.child({ component: 'DistributedLock' });
|
|
46
|
+
this.defaultTTL = options.ttl || 30000;
|
|
47
|
+
this.defaultRetryCount = options.retryCount || 3;
|
|
48
|
+
this.defaultRetryDelay = options.retryDelay || 200;
|
|
49
|
+
}
|
|
50
|
+
getLockKey(key) {
|
|
51
|
+
return `lock:${key}`;
|
|
52
|
+
}
|
|
53
|
+
generateLockId() {
|
|
54
|
+
return randomBytes(16).toString('hex');
|
|
55
|
+
}
|
|
56
|
+
async executeWithRetry(fn, retryCount = this.defaultRetryCount, retryDelay = this.defaultRetryDelay) {
|
|
57
|
+
let lastError = null;
|
|
58
|
+
for (let i = 0; i < retryCount; i++) {
|
|
59
|
+
try {
|
|
60
|
+
return await fn();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
lastError = error;
|
|
64
|
+
if (i < retryCount - 1) {
|
|
65
|
+
const delay = retryDelay * Math.pow(2, i) * (0.5 + Math.random() * 0.5);
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastError || new Error('Retry failed');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Attempts to acquire the lock for a key.
|
|
74
|
+
*
|
|
75
|
+
* Uses atomic `SET lock:<key> <id> PX <ttl> NX` with exponential backoff
|
|
76
|
+
* retries. Locks expire automatically after `ttl` ms, so a crashed holder
|
|
77
|
+
* never blocks others forever.
|
|
78
|
+
*
|
|
79
|
+
* @param key - The resource to lock, e.g. `'order:42'` (stored as `lock:order:42`).
|
|
80
|
+
* @param ttl - Lock TTL in milliseconds (default: `30000`).
|
|
81
|
+
* @returns `true` when the lock was acquired.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const acquired = await lock.acquire('order:42', 10000);
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
async acquire(key, ttl = this.defaultTTL) {
|
|
89
|
+
const lockKey = this.getLockKey(key);
|
|
90
|
+
const lockId = this.generateLockId();
|
|
91
|
+
return this.executeWithRetry(async () => {
|
|
92
|
+
// Using SET with PX and NX for atomic lock acquisition
|
|
93
|
+
const result = await this.client.raw.set(lockKey, lockId, 'PX', ttl, 'NX');
|
|
94
|
+
return result === 'OK';
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Releases the lock, but only if this process still owns it.
|
|
99
|
+
*
|
|
100
|
+
* Uses an atomic Lua check-and-delete so a lock whose TTL expired (and was
|
|
101
|
+
* re-acquired by someone else) is never removed by the old owner.
|
|
102
|
+
*
|
|
103
|
+
* @param key - The locked resource.
|
|
104
|
+
* @returns `true` if the lock was released, `false` if not owned or missing.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* await lock.release('order:42');
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
async release(key) {
|
|
112
|
+
const lockKey = this.getLockKey(key);
|
|
113
|
+
try {
|
|
114
|
+
// Use Lua script for atomic check-and-delete
|
|
115
|
+
const script = `
|
|
116
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
117
|
+
return redis.call('del', KEYS[1])
|
|
118
|
+
else
|
|
119
|
+
return 0
|
|
120
|
+
end
|
|
121
|
+
`;
|
|
122
|
+
const lockId = await this.client.raw.get(lockKey);
|
|
123
|
+
if (!lockId) {
|
|
124
|
+
this.logger.warn('Lock not found for release', { key });
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const result = await this.client.raw.eval(script, 1, lockKey, lockId);
|
|
128
|
+
return result === 1;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.logger.error('Failed to release lock', { key, error });
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Force-releases a lock without checking ownership.
|
|
137
|
+
*
|
|
138
|
+
* Use with care: only for emergency cleanup or when the holder is known to
|
|
139
|
+
* be gone. This is what `withLock` falls back to when a normal release fails.
|
|
140
|
+
*
|
|
141
|
+
* @param key - The locked resource.
|
|
142
|
+
* @returns `true` if a lock existed and was deleted.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* await lock.releaseForce('order:42');
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
async releaseForce(key) {
|
|
150
|
+
const lockKey = this.getLockKey(key);
|
|
151
|
+
const result = await this.client.raw.del(lockKey);
|
|
152
|
+
return result === 1;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Extends the TTL of a lock this process still owns.
|
|
156
|
+
*
|
|
157
|
+
* Uses an atomic Lua script so a re-acquired lock is never extended by the
|
|
158
|
+
* old owner.
|
|
159
|
+
*
|
|
160
|
+
* @param key - The locked resource.
|
|
161
|
+
* @param ttl - New TTL in milliseconds (default: `30000`).
|
|
162
|
+
* @returns `true` if the lock was extended.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* const extended = await lock.extend('order:42', 30000);
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
async extend(key, ttl = this.defaultTTL) {
|
|
170
|
+
const lockKey = this.getLockKey(key);
|
|
171
|
+
const script = `
|
|
172
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
173
|
+
return redis.call('pexpire', KEYS[1], ARGV[2])
|
|
174
|
+
else
|
|
175
|
+
return 0
|
|
176
|
+
end
|
|
177
|
+
`;
|
|
178
|
+
try {
|
|
179
|
+
const lockId = await this.client.raw.get(lockKey);
|
|
180
|
+
if (!lockId) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const result = await this.client.raw.eval(script, 1, lockKey, lockId, ttl);
|
|
184
|
+
return result === 1;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
this.logger.error('Failed to extend lock', { key, error });
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Runs a critical section while holding a lock.
|
|
193
|
+
*
|
|
194
|
+
* Acquires the lock (with retries), auto-extends it at half TTL while `fn`
|
|
195
|
+
* runs, detects a lost lock, and always releases afterwards (force-releasing
|
|
196
|
+
* if a normal release fails).
|
|
197
|
+
*
|
|
198
|
+
* @param key - The resource to lock.
|
|
199
|
+
* @param fn - The critical section to run exclusively.
|
|
200
|
+
* @param options - Per-call `ttl` (ms), `retryCount`, `retryDelay`.
|
|
201
|
+
* @returns The return value of `fn`.
|
|
202
|
+
* @throws {@link RedisError} with code `LOCK_ACQUISITION_FAILED` when the lock
|
|
203
|
+
* cannot be acquired, or `LOCK_LOST` when the lock expired mid-execution.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* const result = await lock.withLock('inventory:sku-1', async () => {
|
|
208
|
+
* return await updateStock();
|
|
209
|
+
* });
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
async withLock(key, fn, options = {}) {
|
|
213
|
+
const ttl = options.ttl || this.defaultTTL;
|
|
214
|
+
const retryCount = options.retryCount || this.defaultRetryCount;
|
|
215
|
+
const retryDelay = options.retryDelay || this.defaultRetryDelay;
|
|
216
|
+
// Try to acquire the lock with retries
|
|
217
|
+
const acquired = await this.acquire(key, ttl);
|
|
218
|
+
if (!acquired) {
|
|
219
|
+
throw new RedisError(`Failed to acquire lock for key: ${key} after ${retryCount} attempts`, 'LOCK_ACQUISITION_FAILED');
|
|
220
|
+
}
|
|
221
|
+
let extensionTimer = null;
|
|
222
|
+
let lockRenewed = true;
|
|
223
|
+
try {
|
|
224
|
+
// Start auto-extension timer at half TTL
|
|
225
|
+
const extendInterval = Math.floor(ttl / 2);
|
|
226
|
+
let isExtending = false;
|
|
227
|
+
const extendLock = async () => {
|
|
228
|
+
if (isExtending || !lockRenewed)
|
|
229
|
+
return;
|
|
230
|
+
isExtending = true;
|
|
231
|
+
try {
|
|
232
|
+
const extended = await this.extend(key, ttl);
|
|
233
|
+
if (!extended) {
|
|
234
|
+
lockRenewed = false;
|
|
235
|
+
this.logger.warn('Lock extension failed', { key });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
this.logger.error('Lock extension error', { key, error });
|
|
240
|
+
lockRenewed = false;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
isExtending = false;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
// Schedule auto-extension
|
|
247
|
+
extensionTimer = setInterval(() => {
|
|
248
|
+
extendLock().catch((error) => {
|
|
249
|
+
this.logger.error('Extension interval error', { key, error });
|
|
250
|
+
});
|
|
251
|
+
}, extendInterval);
|
|
252
|
+
// Execute the function
|
|
253
|
+
const result = await fn();
|
|
254
|
+
// Check if lock was maintained during execution
|
|
255
|
+
if (!lockRenewed) {
|
|
256
|
+
throw new RedisError(`Lock was lost during execution for key: ${key}`, 'LOCK_LOST');
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.logger.error('Error in locked operation', { key, error });
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
// Clean up extension timer
|
|
266
|
+
if (extensionTimer) {
|
|
267
|
+
clearInterval(extensionTimer);
|
|
268
|
+
extensionTimer = null;
|
|
269
|
+
}
|
|
270
|
+
// Release the lock
|
|
271
|
+
try {
|
|
272
|
+
await this.release(key);
|
|
273
|
+
}
|
|
274
|
+
catch (releaseError) {
|
|
275
|
+
this.logger.error('Failed to release lock', { key, releaseError });
|
|
276
|
+
try {
|
|
277
|
+
await this.releaseForce(key);
|
|
278
|
+
}
|
|
279
|
+
catch (forceError) {
|
|
280
|
+
this.logger.error('Failed to force release lock', { key, forceError });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Checks whether a lock is currently held.
|
|
287
|
+
*
|
|
288
|
+
* @param key - The locked resource.
|
|
289
|
+
* @returns `true` if the lock exists (held by anyone).
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* const busy = await lock.isLocked('order:42');
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
async isLocked(key) {
|
|
297
|
+
const lockKey = this.getLockKey(key);
|
|
298
|
+
const exists = await this.client.raw.exists(lockKey);
|
|
299
|
+
return exists === 1;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Returns details about a lock.
|
|
303
|
+
*
|
|
304
|
+
* @param key - The locked resource.
|
|
305
|
+
* @returns `{ locked: false }` when not held, otherwise `{ locked: true, ttl, lockId }`.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* const info = await lock.getLockInfo('order:42');
|
|
310
|
+
* // { locked: true, ttl: 29, lockId: 'a1b2c3...' }
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
async getLockInfo(key) {
|
|
314
|
+
const lockKey = this.getLockKey(key);
|
|
315
|
+
const exists = await this.client.raw.exists(lockKey);
|
|
316
|
+
if (!exists) {
|
|
317
|
+
return { locked: false };
|
|
318
|
+
}
|
|
319
|
+
const [lockId, ttl] = await Promise.all([
|
|
320
|
+
this.client.raw.get(lockKey),
|
|
321
|
+
this.client.raw.ttl(lockKey),
|
|
322
|
+
]);
|
|
323
|
+
// Build the result object with proper undefined handling
|
|
324
|
+
const result = { locked: true };
|
|
325
|
+
if (lockId !== null && lockId !== undefined) {
|
|
326
|
+
result.lockId = lockId;
|
|
327
|
+
}
|
|
328
|
+
if (ttl !== null && ttl !== undefined && ttl > 0) {
|
|
329
|
+
result.ttl = ttl;
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns the owner id of a lock.
|
|
335
|
+
*
|
|
336
|
+
* @param key - The locked resource.
|
|
337
|
+
* @returns The lock id (random hex token), or `null` when not held.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* ```ts
|
|
341
|
+
* const owner = await lock.getLockOwner('order:42');
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
async getLockOwner(key) {
|
|
345
|
+
const lockKey = this.getLockKey(key);
|
|
346
|
+
return this.client.raw.get(lockKey);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Returns the remaining TTL of a lock in seconds.
|
|
350
|
+
*
|
|
351
|
+
* @param key - The locked resource.
|
|
352
|
+
* @returns Remaining seconds (`0` when not held or expired).
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* const remaining = await lock.getLockTTL('order:42');
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
async getLockTTL(key) {
|
|
360
|
+
const lockKey = this.getLockKey(key);
|
|
361
|
+
const ttl = await this.client.raw.ttl(lockKey);
|
|
362
|
+
return ttl > 0 ? ttl : 0;
|
|
363
|
+
}
|
|
364
|
+
// Clean up all locks (for testing or emergency)
|
|
365
|
+
/**
|
|
366
|
+
* Deletes every lock key (`lock:*`) from Redis.
|
|
367
|
+
*
|
|
368
|
+
* Intended for tests and emergency recovery only.
|
|
369
|
+
*
|
|
370
|
+
* @returns The number of deleted locks.
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```ts
|
|
374
|
+
* const removed = await lock.cleanupAll();
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
async cleanupAll() {
|
|
378
|
+
let deleted = 0;
|
|
379
|
+
for await (const key of this.client.scanIterator('lock:*')) {
|
|
380
|
+
const result = await this.client.raw.del(key);
|
|
381
|
+
deleted += result;
|
|
382
|
+
}
|
|
383
|
+
return deleted;
|
|
384
|
+
}
|
|
385
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type LogMeta = Record<string, unknown>;
|
|
2
|
+
export interface LoggerLike {
|
|
3
|
+
trace(message: string, meta?: LogMeta): void;
|
|
4
|
+
debug(message: string, meta?: LogMeta): void;
|
|
5
|
+
info(message: string, meta?: LogMeta): void;
|
|
6
|
+
warn(message: string, meta?: LogMeta): void;
|
|
7
|
+
error(message: string, meta?: LogMeta): void;
|
|
8
|
+
fatal(message: string, meta?: LogMeta): void;
|
|
9
|
+
child(bindings: LogMeta): LoggerLike;
|
|
10
|
+
}
|
|
11
|
+
export declare const defaultLogger: LoggerLike;
|
|
12
|
+
export declare function createConsoleLogger(bindings?: LogMeta): LoggerLike;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
function mergeMeta(bindings, meta) {
|
|
2
|
+
return {
|
|
3
|
+
...bindings,
|
|
4
|
+
...(meta ?? {}),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
class ConsoleLogger {
|
|
8
|
+
bindings;
|
|
9
|
+
constructor(bindings = {}) {
|
|
10
|
+
this.bindings = bindings;
|
|
11
|
+
}
|
|
12
|
+
trace(message, meta) {
|
|
13
|
+
console.trace(message, mergeMeta(this.bindings, meta));
|
|
14
|
+
}
|
|
15
|
+
debug(message, meta) {
|
|
16
|
+
console.debug(message, mergeMeta(this.bindings, meta));
|
|
17
|
+
}
|
|
18
|
+
info(message, meta) {
|
|
19
|
+
console.info(message, mergeMeta(this.bindings, meta));
|
|
20
|
+
}
|
|
21
|
+
warn(message, meta) {
|
|
22
|
+
console.warn(message, mergeMeta(this.bindings, meta));
|
|
23
|
+
}
|
|
24
|
+
error(message, meta) {
|
|
25
|
+
console.error(message, mergeMeta(this.bindings, meta));
|
|
26
|
+
}
|
|
27
|
+
fatal(message, meta) {
|
|
28
|
+
console.error(message, mergeMeta(this.bindings, meta));
|
|
29
|
+
}
|
|
30
|
+
child(bindings) {
|
|
31
|
+
return new ConsoleLogger({
|
|
32
|
+
...this.bindings,
|
|
33
|
+
...bindings,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const defaultLogger = new ConsoleLogger();
|
|
38
|
+
export function createConsoleLogger(bindings) {
|
|
39
|
+
return new ConsoleLogger(bindings);
|
|
40
|
+
}
|