@memberjunction/redis-provider 0.0.1 → 5.9.0
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 +307 -28
- package/dist/RedisLocalStorageProvider.d.ts +491 -0
- package/dist/RedisLocalStorageProvider.d.ts.map +1 -0
- package/dist/RedisLocalStorageProvider.js +740 -0
- package/dist/RedisLocalStorageProvider.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -7
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Redis-backed implementation of {@link ILocalStorageProvider}.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a drop-in replacement for `InMemoryLocalStorageProvider`
|
|
5
|
+
* that persists data in Redis, enabling:
|
|
6
|
+
*
|
|
7
|
+
* - **Shared caching** across multiple MJAPI server instances (horizontal scaling)
|
|
8
|
+
* - **Persistence** across process restarts
|
|
9
|
+
* - **Native TTL** via Redis `EXPIRE` — expired keys are automatically reclaimed
|
|
10
|
+
*
|
|
11
|
+
* Compatible with any Redis-protocol service: self-hosted Redis, Azure Managed Redis,
|
|
12
|
+
* AWS ElastiCache, Redis Cloud, Upstash, etc.
|
|
13
|
+
*
|
|
14
|
+
* @module @memberjunction/redis-provider
|
|
15
|
+
*/
|
|
16
|
+
import Redis from 'ioredis';
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import { LogStatus, LogError } from '@memberjunction/core';
|
|
19
|
+
import { MJGlobal } from '@memberjunction/global';
|
|
20
|
+
/**
|
|
21
|
+
* Default category used when none is specified in storage operations.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_CATEGORY = 'default';
|
|
25
|
+
/**
|
|
26
|
+
* Redis-backed implementation of the MemberJunction {@link ILocalStorageProvider} interface.
|
|
27
|
+
*
|
|
28
|
+
* Provides persistent, shared caching for server-side environments using Redis.
|
|
29
|
+
* This is a drop-in replacement for `InMemoryLocalStorageProvider` — all consumers
|
|
30
|
+
* (like `LocalCacheManager`, `ProviderBase` metadata caching, etc.) work without
|
|
31
|
+
* any code changes.
|
|
32
|
+
*
|
|
33
|
+
* ### Key Structure
|
|
34
|
+
*
|
|
35
|
+
* All keys follow the pattern: `{prefix}:{category}:{key}`
|
|
36
|
+
*
|
|
37
|
+
* - **prefix** — configurable, defaults to `"mj"` to isolate MJ data in shared Redis instances
|
|
38
|
+
* - **category** — maps to the MJ cache category (`RunViewCache`, `Metadata`, `DatasetCache`, etc.)
|
|
39
|
+
* - **key** — the original key from the caller
|
|
40
|
+
*
|
|
41
|
+
* Categories are tracked in a Redis Set at `{prefix}:__categories__:{category}` so that
|
|
42
|
+
* `ClearCategory()` and `GetCategoryKeys()` operations are efficient.
|
|
43
|
+
*
|
|
44
|
+
* ### TTL Support
|
|
45
|
+
*
|
|
46
|
+
* Redis has native key expiration. The provider supports TTL at two levels:
|
|
47
|
+
* 1. **`defaultTTLSeconds`** in config — applied to all `SetItem` calls
|
|
48
|
+
* 2. **`ttlSeconds` parameter** on `SetItem` — overrides the default per-call
|
|
49
|
+
*
|
|
50
|
+
* ### Error Handling
|
|
51
|
+
*
|
|
52
|
+
* Redis operations are wrapped in try/catch blocks. Connection errors are logged
|
|
53
|
+
* via `LogError()` but do not throw — the provider gracefully returns `null` for
|
|
54
|
+
* reads and silently skips writes. This prevents a Redis outage from crashing the
|
|
55
|
+
* application. The `ioredis` client handles automatic reconnection.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
|
|
60
|
+
*
|
|
61
|
+
* const provider = new RedisLocalStorageProvider({
|
|
62
|
+
* url: 'redis://localhost:6379',
|
|
63
|
+
* defaultTTLSeconds: 300 // 5-minute default TTL
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* await provider.SetItem('user:123', JSON.stringify(userData), 'UserCache');
|
|
67
|
+
* const cached = await provider.GetItem('user:123', 'UserCache');
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export class RedisLocalStorageProvider {
|
|
71
|
+
/**
|
|
72
|
+
* Creates a new Redis local storage provider and establishes a connection.
|
|
73
|
+
*
|
|
74
|
+
* The constructor sets up the `ioredis` client with automatic reconnection,
|
|
75
|
+
* error handling, and optional logging. The client connects lazily on the
|
|
76
|
+
* first command, so construction itself does not block.
|
|
77
|
+
*
|
|
78
|
+
* @param config - Redis connection and behavior configuration.
|
|
79
|
+
* At minimum, provide either `url` or `options`.
|
|
80
|
+
* If neither is provided, connects to `localhost:6379`.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* // Connect to local Redis
|
|
85
|
+
* const provider = new RedisLocalStorageProvider({});
|
|
86
|
+
*
|
|
87
|
+
* // Connect to Azure Managed Redis with TLS
|
|
88
|
+
* const provider = new RedisLocalStorageProvider({
|
|
89
|
+
* url: 'rediss://default:ACCESS_KEY@myredis.redis.cache.windows.net:6380',
|
|
90
|
+
* defaultTTLSeconds: 600
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* // Connect to AWS ElastiCache
|
|
94
|
+
* const provider = new RedisLocalStorageProvider({
|
|
95
|
+
* options: {
|
|
96
|
+
* host: 'my-cluster.abc123.use1.cache.amazonaws.com',
|
|
97
|
+
* port: 6379,
|
|
98
|
+
* tls: {}
|
|
99
|
+
* }
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
constructor(config = {}) {
|
|
104
|
+
this._connected = false;
|
|
105
|
+
this._subscriber = null;
|
|
106
|
+
this._eventEmitter = new EventEmitter();
|
|
107
|
+
this._subscriberConnected = false;
|
|
108
|
+
this._config = config;
|
|
109
|
+
this._keyPrefix = config.keyPrefix ?? 'mj';
|
|
110
|
+
this._defaultTTLSeconds = config.defaultTTLSeconds;
|
|
111
|
+
this._enableLogging = config.enableLogging ?? true;
|
|
112
|
+
this._enablePubSub = config.enablePubSub ?? false;
|
|
113
|
+
this._pubSubChannel = `${this._keyPrefix}:__pubsub__`;
|
|
114
|
+
const maxRetries = config.maxRetries ?? 10;
|
|
115
|
+
if (config.url) {
|
|
116
|
+
this._client = new Redis(config.url, {
|
|
117
|
+
maxRetriesPerRequest: null,
|
|
118
|
+
retryStrategy: (times) => this.retryStrategy(times, maxRetries),
|
|
119
|
+
lazyConnect: false,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this._client = new Redis({
|
|
124
|
+
host: 'localhost',
|
|
125
|
+
port: 6379,
|
|
126
|
+
maxRetriesPerRequest: null,
|
|
127
|
+
retryStrategy: (times) => this.retryStrategy(times, maxRetries),
|
|
128
|
+
lazyConnect: false,
|
|
129
|
+
...config.options,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.setupEventHandlers();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Exponential backoff retry strategy for Redis connections.
|
|
136
|
+
* Doubles the delay on each attempt (capped at 30 seconds) and gives up
|
|
137
|
+
* after `maxRetries` attempts.
|
|
138
|
+
*
|
|
139
|
+
* @param times - Current retry attempt number (1-based)
|
|
140
|
+
* @param maxRetries - Maximum number of retries before giving up
|
|
141
|
+
* @returns Delay in milliseconds, or `null` to stop retrying
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
retryStrategy(times, maxRetries) {
|
|
145
|
+
if (times > maxRetries) {
|
|
146
|
+
if (this._enableLogging) {
|
|
147
|
+
LogError(`Redis: max retries (${maxRetries}) exceeded, giving up`);
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
// Exponential backoff: 200ms, 400ms, 800ms, ... capped at 30s
|
|
152
|
+
const delay = Math.min(times * 200, 30000);
|
|
153
|
+
if (this._enableLogging) {
|
|
154
|
+
LogStatus(`Redis: reconnecting in ${delay}ms (attempt ${times}/${maxRetries})`);
|
|
155
|
+
}
|
|
156
|
+
return delay;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Registers event handlers on the ioredis client for logging connection
|
|
160
|
+
* lifecycle events (connect, ready, close, error, reconnecting).
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
setupEventHandlers() {
|
|
164
|
+
this._client.on('connect', () => {
|
|
165
|
+
this._connected = true;
|
|
166
|
+
if (this._enableLogging) {
|
|
167
|
+
LogStatus('Redis: connected');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
this._client.on('ready', () => {
|
|
171
|
+
this._connected = true;
|
|
172
|
+
if (this._enableLogging) {
|
|
173
|
+
LogStatus('Redis: ready to accept commands');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
this._client.on('close', () => {
|
|
177
|
+
this._connected = false;
|
|
178
|
+
if (this._enableLogging) {
|
|
179
|
+
LogStatus('Redis: connection closed');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
this._client.on('error', (err) => {
|
|
183
|
+
if (this._enableLogging) {
|
|
184
|
+
LogError(`Redis: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
this._client.on('reconnecting', () => {
|
|
188
|
+
if (this._enableLogging) {
|
|
189
|
+
LogStatus('Redis: reconnecting...');
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Builds the full Redis key from a category and key name.
|
|
195
|
+
*
|
|
196
|
+
* Format: `{prefix}:{category}:{key}`
|
|
197
|
+
*
|
|
198
|
+
* @param key - The storage key
|
|
199
|
+
* @param category - The category for key isolation
|
|
200
|
+
* @returns The fully-qualified Redis key string
|
|
201
|
+
* @internal
|
|
202
|
+
*/
|
|
203
|
+
buildKey(key, category) {
|
|
204
|
+
return `${this._keyPrefix}:${category}:${key}`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Builds the Redis Set key used to track all keys in a category.
|
|
208
|
+
*
|
|
209
|
+
* Format: `{prefix}:__categories__:{category}`
|
|
210
|
+
*
|
|
211
|
+
* @param category - The category name
|
|
212
|
+
* @returns The Redis key for the category's membership set
|
|
213
|
+
* @internal
|
|
214
|
+
*/
|
|
215
|
+
buildCategorySetKey(category) {
|
|
216
|
+
return `${this._keyPrefix}:__categories__:${category}`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Retrieves a value from Redis by key and optional category.
|
|
220
|
+
*
|
|
221
|
+
* @param key - The key to look up
|
|
222
|
+
* @param category - Optional category for key isolation (defaults to `"default"`)
|
|
223
|
+
* @returns The stored string value, or `null` if the key doesn't exist or Redis is unavailable
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```typescript
|
|
227
|
+
* const value = await provider.GetItem('entity-metadata', 'Metadata');
|
|
228
|
+
* if (value) {
|
|
229
|
+
* const metadata = JSON.parse(value);
|
|
230
|
+
* }
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
async GetItem(key, category) {
|
|
234
|
+
try {
|
|
235
|
+
const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
|
|
236
|
+
return await this._client.get(redisKey);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (this._enableLogging) {
|
|
240
|
+
LogError(`Redis GetItem failed for key "${key}": ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Stores a value in Redis under the given key and optional category.
|
|
247
|
+
*
|
|
248
|
+
* If a `ttlSeconds` is provided, the key will automatically expire after that
|
|
249
|
+
* duration. Otherwise, the configured `defaultTTLSeconds` is used. If neither
|
|
250
|
+
* is set, the key persists indefinitely.
|
|
251
|
+
*
|
|
252
|
+
* The key is also added to a Redis Set that tracks all keys in the category,
|
|
253
|
+
* enabling efficient `ClearCategory()` and `GetCategoryKeys()` operations.
|
|
254
|
+
*
|
|
255
|
+
* @param key - The key to store under
|
|
256
|
+
* @param value - The string value to store
|
|
257
|
+
* @param category - Optional category for key isolation (defaults to `"default"`)
|
|
258
|
+
* @param ttlSeconds - Optional time-to-live in seconds. Overrides `defaultTTLSeconds` from config.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* // Store with default TTL
|
|
263
|
+
* await provider.SetItem('view:users', JSON.stringify(results), 'RunViewCache');
|
|
264
|
+
*
|
|
265
|
+
* // Store with explicit 10-minute TTL
|
|
266
|
+
* await provider.SetItem('view:users', JSON.stringify(results), 'RunViewCache', 600);
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
async SetItem(key, value, category, ttlSeconds) {
|
|
270
|
+
try {
|
|
271
|
+
const cat = category ?? DEFAULT_CATEGORY;
|
|
272
|
+
const redisKey = this.buildKey(key, cat);
|
|
273
|
+
const categorySetKey = this.buildCategorySetKey(cat);
|
|
274
|
+
const effectiveTTL = ttlSeconds ?? this._defaultTTLSeconds;
|
|
275
|
+
// Use pipeline for atomic set + category tracking
|
|
276
|
+
const pipeline = this._client.pipeline();
|
|
277
|
+
if (effectiveTTL && effectiveTTL > 0) {
|
|
278
|
+
pipeline.setex(redisKey, effectiveTTL, value);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
pipeline.set(redisKey, value);
|
|
282
|
+
}
|
|
283
|
+
// Track this key in the category set for ClearCategory/GetCategoryKeys
|
|
284
|
+
pipeline.sadd(categorySetKey, key);
|
|
285
|
+
await pipeline.exec();
|
|
286
|
+
// Publish cache change event for cross-server invalidation
|
|
287
|
+
this.publishChange(key, cat, 'set', value);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (this._enableLogging) {
|
|
291
|
+
LogError(`Redis SetItem failed for key "${key}": ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Removes a key from Redis and from its category tracking set.
|
|
297
|
+
*
|
|
298
|
+
* @param key - The key to remove
|
|
299
|
+
* @param category - Optional category for key isolation (defaults to `"default"`)
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```typescript
|
|
303
|
+
* await provider.Remove('view:users', 'RunViewCache');
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
async Remove(key, category) {
|
|
307
|
+
try {
|
|
308
|
+
const cat = category ?? DEFAULT_CATEGORY;
|
|
309
|
+
const redisKey = this.buildKey(key, cat);
|
|
310
|
+
const categorySetKey = this.buildCategorySetKey(cat);
|
|
311
|
+
const pipeline = this._client.pipeline();
|
|
312
|
+
pipeline.del(redisKey);
|
|
313
|
+
pipeline.srem(categorySetKey, key);
|
|
314
|
+
await pipeline.exec();
|
|
315
|
+
// Publish cache change event for cross-server invalidation
|
|
316
|
+
this.publishChange(key, cat, 'removed');
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
if (this._enableLogging) {
|
|
320
|
+
LogError(`Redis Remove failed for key "${key}": ${err.message}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Clears all keys belonging to a specific category.
|
|
326
|
+
*
|
|
327
|
+
* Uses the category tracking Set to find all member keys, deletes them
|
|
328
|
+
* in a single pipeline call, then removes the tracking Set itself.
|
|
329
|
+
*
|
|
330
|
+
* @param category - The category to clear. If empty, clears the `"default"` category.
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```typescript
|
|
334
|
+
* // Clear all cached RunView results
|
|
335
|
+
* await provider.ClearCategory('RunViewCache');
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
async ClearCategory(category) {
|
|
339
|
+
try {
|
|
340
|
+
const cat = category || DEFAULT_CATEGORY;
|
|
341
|
+
const categorySetKey = this.buildCategorySetKey(cat);
|
|
342
|
+
// Get all keys in this category
|
|
343
|
+
const keys = await this._client.smembers(categorySetKey);
|
|
344
|
+
if (keys.length > 0) {
|
|
345
|
+
const pipeline = this._client.pipeline();
|
|
346
|
+
// Delete each key
|
|
347
|
+
for (const key of keys) {
|
|
348
|
+
pipeline.del(this.buildKey(key, cat));
|
|
349
|
+
}
|
|
350
|
+
// Delete the category set itself
|
|
351
|
+
pipeline.del(categorySetKey);
|
|
352
|
+
await pipeline.exec();
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
// Category set might still exist even if empty
|
|
356
|
+
await this._client.del(categorySetKey);
|
|
357
|
+
}
|
|
358
|
+
// Publish category-level change event
|
|
359
|
+
this.publishChange(cat, cat, 'category_cleared');
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
if (this._enableLogging) {
|
|
363
|
+
LogError(`Redis ClearCategory failed for "${category}": ${err.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Returns all keys belonging to a specific category.
|
|
369
|
+
*
|
|
370
|
+
* Reads from the category tracking Set, so the result reflects keys
|
|
371
|
+
* that were added via `SetItem` (some may have expired via TTL but
|
|
372
|
+
* will still appear in the set until cleaned up).
|
|
373
|
+
*
|
|
374
|
+
* @param category - The category to list keys from
|
|
375
|
+
* @returns Array of original key names (without the Redis prefix/category prefix)
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```typescript
|
|
379
|
+
* const keys = await provider.GetCategoryKeys('RunViewCache');
|
|
380
|
+
* console.log(`${keys.length} cached views`);
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
async GetCategoryKeys(category) {
|
|
384
|
+
try {
|
|
385
|
+
const cat = category || DEFAULT_CATEGORY;
|
|
386
|
+
const categorySetKey = this.buildCategorySetKey(cat);
|
|
387
|
+
return await this._client.smembers(categorySetKey);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
if (this._enableLogging) {
|
|
391
|
+
LogError(`Redis GetCategoryKeys failed for "${category}": ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Whether the Redis client currently has an active connection.
|
|
398
|
+
*
|
|
399
|
+
* Note: `ioredis` automatically reconnects on failure, so a `false` value
|
|
400
|
+
* here is usually transient. The provider continues to accept commands
|
|
401
|
+
* (they queue until reconnection succeeds or retry limit is hit).
|
|
402
|
+
*/
|
|
403
|
+
get IsConnected() {
|
|
404
|
+
return this._connected;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Returns the underlying `ioredis` client instance for advanced operations.
|
|
408
|
+
*
|
|
409
|
+
* Use with caution — direct client access bypasses key prefixing and
|
|
410
|
+
* category tracking. Prefer the `ILocalStorageProvider` methods for
|
|
411
|
+
* standard operations.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```typescript
|
|
415
|
+
* // Use for Redis-specific commands like pub/sub, streams, etc.
|
|
416
|
+
* const client = provider.Client;
|
|
417
|
+
* await client.publish('cache-invalidation', 'entity:Users');
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
get Client() {
|
|
421
|
+
return this._client;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Gracefully disconnects from Redis.
|
|
425
|
+
*
|
|
426
|
+
* Sends a `QUIT` command and waits for pending replies. After calling this
|
|
427
|
+
* method, the provider should not be used for further operations.
|
|
428
|
+
*
|
|
429
|
+
* Call this during application shutdown to ensure clean disconnection.
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* ```typescript
|
|
433
|
+
* // During application shutdown
|
|
434
|
+
* process.on('SIGTERM', async () => {
|
|
435
|
+
* await redisProvider.Disconnect();
|
|
436
|
+
* process.exit(0);
|
|
437
|
+
* });
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
async Disconnect() {
|
|
441
|
+
// Disconnect subscriber first if active
|
|
442
|
+
if (this._subscriber) {
|
|
443
|
+
try {
|
|
444
|
+
await this._subscriber.quit();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
this._subscriber.disconnect();
|
|
448
|
+
}
|
|
449
|
+
this._subscriber = null;
|
|
450
|
+
this._subscriberConnected = false;
|
|
451
|
+
}
|
|
452
|
+
// Remove all event listeners
|
|
453
|
+
this._eventEmitter.removeAllListeners();
|
|
454
|
+
try {
|
|
455
|
+
await this._client.quit();
|
|
456
|
+
this._connected = false;
|
|
457
|
+
if (this._enableLogging) {
|
|
458
|
+
LogStatus('Redis: disconnected gracefully');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
if (this._enableLogging) {
|
|
463
|
+
LogError(`Redis disconnect error: ${err.message}`);
|
|
464
|
+
}
|
|
465
|
+
// Force disconnect if graceful quit fails
|
|
466
|
+
this._client.disconnect();
|
|
467
|
+
this._connected = false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Checks if a key exists in the specified category.
|
|
472
|
+
*
|
|
473
|
+
* This is more efficient than `GetItem()` when you only need to check
|
|
474
|
+
* existence without retrieving the value (avoids transferring the value
|
|
475
|
+
* over the network).
|
|
476
|
+
*
|
|
477
|
+
* @param key - The key to check
|
|
478
|
+
* @param category - Optional category for key isolation (defaults to `"default"`)
|
|
479
|
+
* @returns `true` if the key exists and has not expired
|
|
480
|
+
*
|
|
481
|
+
* @example
|
|
482
|
+
* ```typescript
|
|
483
|
+
* if (await provider.Exists('view:users', 'RunViewCache')) {
|
|
484
|
+
* // Use cached value
|
|
485
|
+
* }
|
|
486
|
+
* ```
|
|
487
|
+
*/
|
|
488
|
+
async Exists(key, category) {
|
|
489
|
+
try {
|
|
490
|
+
const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
|
|
491
|
+
const result = await this._client.exists(redisKey);
|
|
492
|
+
return result === 1;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
if (this._enableLogging) {
|
|
496
|
+
LogError(`Redis Exists failed for key "${key}": ${err.message}`);
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Returns the remaining time-to-live (in seconds) for a key.
|
|
503
|
+
*
|
|
504
|
+
* @param key - The key to check
|
|
505
|
+
* @param category - Optional category for key isolation (defaults to `"default"`)
|
|
506
|
+
* @returns TTL in seconds, `-1` if no expiration is set, `-2` if the key doesn't exist,
|
|
507
|
+
* or `null` if Redis is unavailable
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* ```typescript
|
|
511
|
+
* const ttl = await provider.GetTTL('view:users', 'RunViewCache');
|
|
512
|
+
* if (ttl !== null && ttl > 0) {
|
|
513
|
+
* console.log(`Key expires in ${ttl} seconds`);
|
|
514
|
+
* }
|
|
515
|
+
* ```
|
|
516
|
+
*/
|
|
517
|
+
async GetTTL(key, category) {
|
|
518
|
+
try {
|
|
519
|
+
const redisKey = this.buildKey(key, category ?? DEFAULT_CATEGORY);
|
|
520
|
+
return await this._client.ttl(redisKey);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
if (this._enableLogging) {
|
|
524
|
+
LogError(`Redis GetTTL failed for key "${key}": ${err.message}`);
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Pings the Redis server to verify connectivity.
|
|
531
|
+
*
|
|
532
|
+
* Useful for health checks and connection validation.
|
|
533
|
+
*
|
|
534
|
+
* @returns `true` if the server responds with `PONG`, `false` otherwise
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```typescript
|
|
538
|
+
* const healthy = await provider.Ping();
|
|
539
|
+
* if (!healthy) {
|
|
540
|
+
* console.error('Redis is unreachable');
|
|
541
|
+
* }
|
|
542
|
+
* ```
|
|
543
|
+
*/
|
|
544
|
+
async Ping() {
|
|
545
|
+
try {
|
|
546
|
+
const result = await this._client.ping();
|
|
547
|
+
return result === 'PONG';
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// ========================================================================
|
|
554
|
+
// PUB/SUB — Cross-Server Cache Invalidation
|
|
555
|
+
// ========================================================================
|
|
556
|
+
/**
|
|
557
|
+
* Starts listening for cache change events from other server instances.
|
|
558
|
+
* Creates a dedicated Redis connection for pub/sub (required by Redis protocol —
|
|
559
|
+
* a client in subscribe mode cannot execute other commands).
|
|
560
|
+
*
|
|
561
|
+
* Must be called explicitly after construction. No-op if `enablePubSub` is `false`
|
|
562
|
+
* in the config, or if already listening.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* ```typescript
|
|
566
|
+
* const provider = new RedisLocalStorageProvider({
|
|
567
|
+
* url: 'redis://localhost:6379',
|
|
568
|
+
* enablePubSub: true
|
|
569
|
+
* });
|
|
570
|
+
* await provider.StartListening();
|
|
571
|
+
*
|
|
572
|
+
* // Register for change events
|
|
573
|
+
* provider.OnCacheChanged((event) => {
|
|
574
|
+
* console.log(`Key "${event.CacheKey}" changed by server ${event.SourceServerId}`);
|
|
575
|
+
* });
|
|
576
|
+
* ```
|
|
577
|
+
*/
|
|
578
|
+
async StartListening() {
|
|
579
|
+
if (!this._enablePubSub) {
|
|
580
|
+
if (this._enableLogging) {
|
|
581
|
+
LogStatus('Redis pub/sub: not enabled (set enablePubSub: true in config)');
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (this._subscriber) {
|
|
586
|
+
// Already listening
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this._subscriber = this.createSubscriberClient();
|
|
590
|
+
this.setupSubscriberEventHandlers();
|
|
591
|
+
await this._subscriber.subscribe(this._pubSubChannel);
|
|
592
|
+
if (this._enableLogging) {
|
|
593
|
+
LogStatus(`Redis pub/sub: subscribed to channel "${this._pubSubChannel}"`);
|
|
594
|
+
}
|
|
595
|
+
this._subscriber.on('message', (channel, message) => {
|
|
596
|
+
if (channel !== this._pubSubChannel) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
this.handlePubSubMessage(message);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Creates the subscriber Redis client, mirroring the main client's connection config.
|
|
604
|
+
* @internal
|
|
605
|
+
*/
|
|
606
|
+
createSubscriberClient() {
|
|
607
|
+
const maxRetries = this._config.maxRetries ?? 10;
|
|
608
|
+
if (this._config.url) {
|
|
609
|
+
return new Redis(this._config.url, {
|
|
610
|
+
maxRetriesPerRequest: null,
|
|
611
|
+
retryStrategy: (times) => this.retryStrategy(times, maxRetries),
|
|
612
|
+
lazyConnect: false,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return new Redis({
|
|
616
|
+
host: 'localhost',
|
|
617
|
+
port: 6379,
|
|
618
|
+
maxRetriesPerRequest: null,
|
|
619
|
+
retryStrategy: (times) => this.retryStrategy(times, maxRetries),
|
|
620
|
+
lazyConnect: false,
|
|
621
|
+
...this._config.options,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Sets up event handlers on the subscriber client for logging.
|
|
626
|
+
* @internal
|
|
627
|
+
*/
|
|
628
|
+
setupSubscriberEventHandlers() {
|
|
629
|
+
if (!this._subscriber)
|
|
630
|
+
return;
|
|
631
|
+
this._subscriber.on('connect', () => {
|
|
632
|
+
this._subscriberConnected = true;
|
|
633
|
+
if (this._enableLogging) {
|
|
634
|
+
LogStatus('Redis pub/sub subscriber: connected');
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
this._subscriber.on('close', () => {
|
|
638
|
+
this._subscriberConnected = false;
|
|
639
|
+
});
|
|
640
|
+
this._subscriber.on('error', (err) => {
|
|
641
|
+
if (this._enableLogging) {
|
|
642
|
+
LogError(`Redis pub/sub subscriber: ${err.message}`);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Handles an incoming pub/sub message. Parses the {@link CacheChangedEvent},
|
|
648
|
+
* filters out self-originated events, and emits to local listeners.
|
|
649
|
+
* @internal
|
|
650
|
+
*/
|
|
651
|
+
handlePubSubMessage(message) {
|
|
652
|
+
try {
|
|
653
|
+
const event = JSON.parse(message);
|
|
654
|
+
// Skip events from this server instance
|
|
655
|
+
if (event.SourceServerId === MJGlobal.Instance.ProcessUUID) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (this._enableLogging) {
|
|
659
|
+
const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
|
|
660
|
+
LogStatus(`Redis pub/sub: received ${event.Action} event for key "${event.CacheKey}" from server ${sourceShort}`);
|
|
661
|
+
}
|
|
662
|
+
// Emit to local listeners
|
|
663
|
+
this._eventEmitter.emit('cacheChanged', event);
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
if (this._enableLogging) {
|
|
667
|
+
LogError(`Redis pub/sub: failed to parse message: ${err.message}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Publishes a cache change event to Redis pub/sub. Called internally by
|
|
673
|
+
* `SetItem`, `Remove`, and `ClearCategory`. No-op if pub/sub is disabled.
|
|
674
|
+
*
|
|
675
|
+
* @param cacheKey - The cache key that changed
|
|
676
|
+
* @param category - The storage category
|
|
677
|
+
* @param action - What happened ('set', 'removed', 'category_cleared')
|
|
678
|
+
* @param data - The new value (only for 'set' actions)
|
|
679
|
+
* @internal
|
|
680
|
+
*/
|
|
681
|
+
publishChange(cacheKey, category, action, data) {
|
|
682
|
+
if (!this._enablePubSub) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const event = {
|
|
686
|
+
CacheKey: cacheKey,
|
|
687
|
+
Category: category,
|
|
688
|
+
Action: action,
|
|
689
|
+
Timestamp: Date.now(),
|
|
690
|
+
SourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
691
|
+
Data: data,
|
|
692
|
+
};
|
|
693
|
+
// Publish fire-and-forget — don't await, don't block the caller
|
|
694
|
+
const payload = JSON.stringify(event);
|
|
695
|
+
this._client.publish(this._pubSubChannel, payload).then(() => {
|
|
696
|
+
if (this._enableLogging) {
|
|
697
|
+
LogStatus(`Redis pub/sub: published ${action} event for key "${cacheKey}" on channel "${this._pubSubChannel}"`);
|
|
698
|
+
}
|
|
699
|
+
}).catch((err) => {
|
|
700
|
+
if (this._enableLogging) {
|
|
701
|
+
LogError(`Redis pub/sub publish failed: ${err.message}`);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Registers a callback for cache change events from other servers.
|
|
707
|
+
* The callback fires whenever another server instance modifies a cached entry
|
|
708
|
+
* (via `SetItem`, `Remove`, or `ClearCategory`).
|
|
709
|
+
*
|
|
710
|
+
* Events from this server instance (identified by {@link MJGlobal.ProcessUUID})
|
|
711
|
+
* are automatically filtered out.
|
|
712
|
+
*
|
|
713
|
+
* @param callback - Function invoked with the {@link CacheChangedEvent}
|
|
714
|
+
* @returns A function that, when called, removes this callback registration
|
|
715
|
+
*
|
|
716
|
+
* @example
|
|
717
|
+
* ```typescript
|
|
718
|
+
* const unsubscribe = provider.OnCacheChanged((event) => {
|
|
719
|
+
* // Dispatch to LocalCacheManager for callback routing
|
|
720
|
+
* LocalCacheManager.Instance.DispatchCacheChange(event);
|
|
721
|
+
* });
|
|
722
|
+
*
|
|
723
|
+
* // Later, on shutdown:
|
|
724
|
+
* unsubscribe();
|
|
725
|
+
* ```
|
|
726
|
+
*/
|
|
727
|
+
OnCacheChanged(callback) {
|
|
728
|
+
this._eventEmitter.on('cacheChanged', callback);
|
|
729
|
+
return () => {
|
|
730
|
+
this._eventEmitter.off('cacheChanged', callback);
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Whether the pub/sub subscriber connection is currently active.
|
|
735
|
+
*/
|
|
736
|
+
get IsSubscriberConnected() {
|
|
737
|
+
return this._subscriberConnected;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
//# sourceMappingURL=RedisLocalStorageProvider.js.map
|