@ferrox-node/redis 1.1.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.
@@ -0,0 +1,20 @@
1
+ import { Lock } from 'redlock';
2
+ export * from './smart-cache';
3
+ export declare class RedisHelper {
4
+ private client;
5
+ private redlock;
6
+ constructor(redisUrl: string);
7
+ /**
8
+ * Acquire a Distributed Lock to prevent race conditions across microservices
9
+ */
10
+ acquireLock(resource: string, ttlMs: number): Promise<Lock>;
11
+ /**
12
+ * Caches a value with a specific TTL
13
+ */
14
+ setCache(key: string, value: any, ttlSeconds: number): Promise<void>;
15
+ /**
16
+ * Retrieves a cached value
17
+ */
18
+ getCache<T>(key: string): Promise<T | null>;
19
+ close(): Promise<void>;
20
+ }
package/dist/index.js ADDED
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.RedisHelper = void 0;
21
+ const ioredis_1 = __importDefault(require("ioredis"));
22
+ const redlock_1 = __importDefault(require("redlock"));
23
+ const logger_1 = require("@node-yalc/logger");
24
+ __exportStar(require("./smart-cache"), exports);
25
+ const logger = (0, logger_1.AppLoggerFactory)('RedisCloudHelper');
26
+ class RedisHelper {
27
+ client;
28
+ redlock;
29
+ constructor(redisUrl) {
30
+ this.client = new ioredis_1.default(redisUrl);
31
+ // Configure Redlock for Distributed Locking
32
+ this.redlock = new redlock_1.default([this.client], {
33
+ driftFactor: 0.01,
34
+ retryCount: 10,
35
+ retryDelay: 200,
36
+ retryJitter: 200,
37
+ automaticExtensionThreshold: 500,
38
+ });
39
+ this.client.on('connect', () => logger.log('Connected to Redis successfully'));
40
+ this.client.on('error', (err) => logger.error(`Redis connection error: ${err.message}`));
41
+ }
42
+ /**
43
+ * Acquire a Distributed Lock to prevent race conditions across microservices
44
+ */
45
+ async acquireLock(resource, ttlMs) {
46
+ logger.debug?.(`[Redis] Acquiring lock for resource: ${resource} (${ttlMs}ms)`);
47
+ return this.redlock.acquire([resource], ttlMs);
48
+ }
49
+ /**
50
+ * Caches a value with a specific TTL
51
+ */
52
+ async setCache(key, value, ttlSeconds) {
53
+ const serialized = JSON.stringify(value);
54
+ await this.client.set(key, serialized, 'EX', ttlSeconds);
55
+ }
56
+ /**
57
+ * Retrieves a cached value
58
+ */
59
+ async getCache(key) {
60
+ const data = await this.client.get(key);
61
+ if (!data)
62
+ return null;
63
+ return JSON.parse(data);
64
+ }
65
+ async close() {
66
+ await this.client.quit();
67
+ }
68
+ }
69
+ exports.RedisHelper = RedisHelper;
@@ -0,0 +1,23 @@
1
+ import { RedisHelper } from './index';
2
+ export interface CacheFetchOptions {
3
+ userId: string;
4
+ resourceName: string;
5
+ ttlSeconds: number;
6
+ fetcher: () => Promise<any>;
7
+ }
8
+ export declare class SmartCacheManager {
9
+ private inFlightPromises;
10
+ private redis;
11
+ constructor(redisHelper: RedisHelper);
12
+ /**
13
+ * Generates a hyper-strict, isolated cache key.
14
+ */
15
+ private generateKey;
16
+ /**
17
+ * Fetches data with Thundering Herd & Cache Stampede Protection (Multi-tier).
18
+ * 1. In-Memory Promise Deduplication (Single-Node Lock)
19
+ * 2. Redis Distributed Lock (Multi-Node Lock)
20
+ */
21
+ getOrFetch<T>(options: CacheFetchOptions): Promise<T>;
22
+ private internalFetch;
23
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SmartCacheManager = void 0;
4
+ const logger_1 = require("@node-yalc/logger");
5
+ const logger = (0, logger_1.AppLoggerFactory)('SmartCacheManager');
6
+ class SmartCacheManager {
7
+ inFlightPromises = new Map();
8
+ redis;
9
+ constructor(redisHelper) {
10
+ this.redis = redisHelper;
11
+ }
12
+ /**
13
+ * Generates a hyper-strict, isolated cache key.
14
+ */
15
+ generateKey(userId, resourceName) {
16
+ return `ferrox:cache:user:${userId}:res:${resourceName}`;
17
+ }
18
+ /**
19
+ * Fetches data with Thundering Herd & Cache Stampede Protection (Multi-tier).
20
+ * 1. In-Memory Promise Deduplication (Single-Node Lock)
21
+ * 2. Redis Distributed Lock (Multi-Node Lock)
22
+ */
23
+ async getOrFetch(options) {
24
+ const cacheKey = this.generateKey(options.userId, options.resourceName);
25
+ // TIER 1: In-Memory Promise Deduplication.
26
+ // If 10k requests hit THIS node, 9999 will just await the existing promise without hitting Redis or DB!
27
+ if (this.inFlightPromises.has(cacheKey)) {
28
+ logger?.debug?.(`[Cache] Memory multiplex hit for ${cacheKey}. Awaiting existing promise...`);
29
+ return this.inFlightPromises.get(cacheKey);
30
+ }
31
+ const fetchPromise = this.internalFetch(cacheKey, options);
32
+ this.inFlightPromises.set(cacheKey, fetchPromise);
33
+ try {
34
+ return await fetchPromise;
35
+ }
36
+ finally {
37
+ // Always cleanup the in-flight promise regardless of success or failure
38
+ this.inFlightPromises.delete(cacheKey);
39
+ }
40
+ }
41
+ async internalFetch(cacheKey, options) {
42
+ // TIER 2: Check Redis Cache
43
+ const cachedValue = await this.redis.getCache(cacheKey);
44
+ if (cachedValue) {
45
+ logger?.debug?.(`[Cache] Redis hit for ${cacheKey}`);
46
+ return cachedValue;
47
+ }
48
+ // TIER 3: Redis Distributed Lock (Cache Stampede Protection across multiple Node instances)
49
+ const lockKey = `lock:${cacheKey}`;
50
+ logger?.debug?.(`[Cache] Cache miss for ${cacheKey}. Attempting to acquire distributed DB lock...`);
51
+ let lock;
52
+ try {
53
+ // Only 1 node across the whole cluster gets the lock. Others will fail and fallback
54
+ lock = await this.redis.acquireLock(lockKey, 5000); // 5 sec lock
55
+ }
56
+ catch (err) {
57
+ // Failed to acquire lock (another node is fetching).
58
+ // We must wait a bit and retry reading from cache!
59
+ logger.warn(`[Cache] Lock busy for ${cacheKey}. Another instance is fetching. Waiting and retrying...`);
60
+ await new Promise(resolve => setTimeout(resolve, 200));
61
+ const retryCache = await this.redis.getCache(cacheKey);
62
+ if (retryCache)
63
+ return retryCache;
64
+ throw new Error(`Timeout waiting for cache resolution on ${cacheKey}`);
65
+ }
66
+ // We hold the lock! Hit the DB.
67
+ try {
68
+ logger?.debug?.(`[Cache] Lock acquired. Executing DB fetcher for ${cacheKey}`);
69
+ const freshData = await options.fetcher();
70
+ // Store in Redis
71
+ await this.redis.setCache(cacheKey, freshData, options.ttlSeconds);
72
+ return freshData;
73
+ }
74
+ catch (err) {
75
+ logger.error(`[Cache] Fetcher failed for ${cacheKey}: ${err.message}`);
76
+ throw err;
77
+ }
78
+ finally {
79
+ // Always release the lock
80
+ await lock.release().catch(e => logger.warn(`[Cache] Error releasing lock: ${e.message}`));
81
+ }
82
+ }
83
+ }
84
+ exports.SmartCacheManager = SmartCacheManager;
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@ferrox-node/redis",
3
+ "version": "1.1.1",
4
+ "description": "Advanced Redis, Distributed Locks, and Caching for Ferrox-Node",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "dependencies": {
11
+ "@ferrox-node/core": "*",
12
+ "ioredis": "^5.4.1",
13
+ "redlock": "^5.0.0-beta.2"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.0.0",
17
+ "@types/node": "^20.0.0"
18
+ }
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ import Redis from 'ioredis';
2
+ import Redlock, { Lock } from 'redlock';
3
+ import { AppLoggerFactory } from '@node-yalc/logger';
4
+ export * from './smart-cache';
5
+
6
+ const logger = AppLoggerFactory('RedisCloudHelper');
7
+
8
+ export class RedisHelper {
9
+ private client: Redis;
10
+ private redlock: Redlock;
11
+
12
+ constructor(redisUrl: string) {
13
+ this.client = new Redis(redisUrl);
14
+
15
+ // Configure Redlock for Distributed Locking
16
+ this.redlock = new Redlock([this.client], {
17
+ driftFactor: 0.01,
18
+ retryCount: 10,
19
+ retryDelay: 200,
20
+ retryJitter: 200,
21
+ automaticExtensionThreshold: 500,
22
+ });
23
+
24
+ this.client.on('connect', () => logger.log('Connected to Redis successfully'));
25
+ this.client.on('error', (err) => logger.error(`Redis connection error: ${err.message}`));
26
+ }
27
+
28
+ /**
29
+ * Acquire a Distributed Lock to prevent race conditions across microservices
30
+ */
31
+ async acquireLock(resource: string, ttlMs: number): Promise<Lock> {
32
+ logger.debug?.(`[Redis] Acquiring lock for resource: ${resource} (${ttlMs}ms)`);
33
+ return this.redlock.acquire([resource], ttlMs);
34
+ }
35
+
36
+ /**
37
+ * Caches a value with a specific TTL
38
+ */
39
+ async setCache(key: string, value: any, ttlSeconds: number): Promise<void> {
40
+ const serialized = JSON.stringify(value);
41
+ await this.client.set(key, serialized, 'EX', ttlSeconds);
42
+ }
43
+
44
+ /**
45
+ * Retrieves a cached value
46
+ */
47
+ async getCache<T>(key: string): Promise<T | null> {
48
+ const data = await this.client.get(key);
49
+ if (!data) return null;
50
+ return JSON.parse(data) as T;
51
+ }
52
+
53
+ async close() {
54
+ await this.client.quit();
55
+ }
56
+ }
@@ -0,0 +1,100 @@
1
+ import { RedisHelper } from './index';
2
+ import { AppLoggerFactory } from '@node-yalc/logger';
3
+
4
+ const logger = AppLoggerFactory('SmartCacheManager');
5
+
6
+ export interface CacheFetchOptions {
7
+ userId: string; // Mandatory to prevent returning one user's data to another!
8
+ resourceName: string; // e.g. 'profile', 'billing'
9
+ ttlSeconds: number;
10
+ fetcher: () => Promise<any>; // The function that actually hits the DB
11
+ }
12
+
13
+ export class SmartCacheManager {
14
+ private inFlightPromises = new Map<string, Promise<any>>();
15
+ private redis: RedisHelper;
16
+
17
+ constructor(redisHelper: RedisHelper) {
18
+ this.redis = redisHelper;
19
+ }
20
+
21
+ /**
22
+ * Generates a hyper-strict, isolated cache key.
23
+ */
24
+ private generateKey(userId: string, resourceName: string): string {
25
+ return `ferrox:cache:user:${userId}:res:${resourceName}`;
26
+ }
27
+
28
+ /**
29
+ * Fetches data with Thundering Herd & Cache Stampede Protection (Multi-tier).
30
+ * 1. In-Memory Promise Deduplication (Single-Node Lock)
31
+ * 2. Redis Distributed Lock (Multi-Node Lock)
32
+ */
33
+ public async getOrFetch<T>(options: CacheFetchOptions): Promise<T> {
34
+ const cacheKey = this.generateKey(options.userId, options.resourceName);
35
+
36
+ // TIER 1: In-Memory Promise Deduplication.
37
+ // If 10k requests hit THIS node, 9999 will just await the existing promise without hitting Redis or DB!
38
+ if (this.inFlightPromises.has(cacheKey)) {
39
+ logger?.debug?.(`[Cache] Memory multiplex hit for ${cacheKey}. Awaiting existing promise...`);
40
+ return this.inFlightPromises.get(cacheKey) as Promise<T>;
41
+ }
42
+
43
+ const fetchPromise = this.internalFetch<T>(cacheKey, options);
44
+ this.inFlightPromises.set(cacheKey, fetchPromise);
45
+
46
+ try {
47
+ return await fetchPromise;
48
+ } finally {
49
+ // Always cleanup the in-flight promise regardless of success or failure
50
+ this.inFlightPromises.delete(cacheKey);
51
+ }
52
+ }
53
+
54
+ private async internalFetch<T>(cacheKey: string, options: CacheFetchOptions): Promise<T> {
55
+ // TIER 2: Check Redis Cache
56
+ const cachedValue = await this.redis.getCache<T>(cacheKey);
57
+ if (cachedValue) {
58
+ logger?.debug?.(`[Cache] Redis hit for ${cacheKey}`);
59
+ return cachedValue;
60
+ }
61
+
62
+ // TIER 3: Redis Distributed Lock (Cache Stampede Protection across multiple Node instances)
63
+ const lockKey = `lock:${cacheKey}`;
64
+ logger?.debug?.(`[Cache] Cache miss for ${cacheKey}. Attempting to acquire distributed DB lock...`);
65
+
66
+ let lock;
67
+ try {
68
+ // Only 1 node across the whole cluster gets the lock. Others will fail and fallback
69
+ lock = await this.redis.acquireLock(lockKey, 5000); // 5 sec lock
70
+ } catch (err) {
71
+ // Failed to acquire lock (another node is fetching).
72
+ // We must wait a bit and retry reading from cache!
73
+ logger.warn(`[Cache] Lock busy for ${cacheKey}. Another instance is fetching. Waiting and retrying...`);
74
+ await new Promise(resolve => setTimeout(resolve, 200));
75
+
76
+ const retryCache = await this.redis.getCache<T>(cacheKey);
77
+ if (retryCache) return retryCache;
78
+
79
+ throw new Error(`Timeout waiting for cache resolution on ${cacheKey}`);
80
+ }
81
+
82
+ // We hold the lock! Hit the DB.
83
+ try {
84
+ logger?.debug?.(`[Cache] Lock acquired. Executing DB fetcher for ${cacheKey}`);
85
+ const freshData = await options.fetcher();
86
+
87
+ // Store in Redis
88
+ await this.redis.setCache(cacheKey, freshData, options.ttlSeconds);
89
+
90
+ return freshData;
91
+ } catch (err: any) {
92
+ logger.error(`[Cache] Fetcher failed for ${cacheKey}: ${err.message}`);
93
+ throw err;
94
+ } finally {
95
+ // Always release the lock
96
+ await lock.release().catch(e => logger.warn(`[Cache] Error releasing lock: ${e.message}`));
97
+ }
98
+ }
99
+ }
100
+
@@ -0,0 +1,91 @@
1
+ import { SmartCacheManager } from '../src/smart-cache';
2
+
3
+ describe('SmartCacheManager', () => {
4
+ it('should deduplicate in-flight promises (Cache Stampede Protection)', async () => {
5
+ // Mock RedisHelper
6
+ const mockRedis = {
7
+ getCache: jest.fn().mockResolvedValue(null),
8
+ acquireLock: jest.fn().mockResolvedValue({ release: jest.fn().mockResolvedValue(true) }),
9
+ setCache: jest.fn().mockResolvedValue(true),
10
+ };
11
+
12
+ const cache = new SmartCacheManager(mockRedis as any);
13
+
14
+ let fetchCount = 0;
15
+ const fetcher = async () => {
16
+ fetchCount++;
17
+ return new Promise(r => setTimeout(() => r('DB_DATA'), 50));
18
+ };
19
+
20
+ // Fire 5 identical requests concurrently
21
+ const p1 = cache.getOrFetch({ userId: 'u1', resourceName: 'profile', ttlSeconds: 60, fetcher });
22
+ const p2 = cache.getOrFetch({ userId: 'u1', resourceName: 'profile', ttlSeconds: 60, fetcher });
23
+ const p3 = cache.getOrFetch({ userId: 'u1', resourceName: 'profile', ttlSeconds: 60, fetcher });
24
+
25
+ const results = await Promise.all([p1, p2, p3]);
26
+
27
+ expect(results).toEqual(['DB_DATA', 'DB_DATA', 'DB_DATA']);
28
+ // Important: fetcher should only be called ONCE despite 3 concurrent calls
29
+ expect(fetchCount).toBe(1);
30
+ expect(mockRedis.acquireLock).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it('should return from redis if cache hits (Tier 2)', async () => {
34
+ const mockRedis = {
35
+ getCache: jest.fn().mockResolvedValue('CACHED_DATA'),
36
+ acquireLock: jest.fn(),
37
+ };
38
+ const cache = new SmartCacheManager(mockRedis as any);
39
+ const result = await cache.getOrFetch({ userId: 'u2', resourceName: 'profile', ttlSeconds: 60, fetcher: async () => 'DB' });
40
+ expect(result).toBe('CACHED_DATA');
41
+ expect(mockRedis.acquireLock).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('should wait and retry cache if lock is busy (Tier 3 fallback)', async () => {
45
+ let getCacheCalls = 0;
46
+ const mockRedis = {
47
+ getCache: jest.fn().mockImplementation(async () => {
48
+ getCacheCalls++;
49
+ if (getCacheCalls === 1) return null; // First try: miss
50
+ return 'RETRY_CACHED_DATA'; // Second try after wait: hit!
51
+ }),
52
+ acquireLock: jest.fn().mockRejectedValue(new Error('Lock busy')),
53
+ };
54
+ const cache = new SmartCacheManager(mockRedis as any);
55
+ const result = await cache.getOrFetch({ userId: 'u3', resourceName: 'profile', ttlSeconds: 60, fetcher: async () => 'DB' });
56
+ expect(result).toBe('RETRY_CACHED_DATA');
57
+ });
58
+
59
+ it('should throw if wait and retry cache fails', async () => {
60
+ const mockRedis = {
61
+ getCache: jest.fn().mockResolvedValue(null),
62
+ acquireLock: jest.fn().mockRejectedValue(new Error('Lock busy')),
63
+ };
64
+ const cache = new SmartCacheManager(mockRedis as any);
65
+ await expect(cache.getOrFetch({ userId: 'u4', resourceName: 'profile', ttlSeconds: 60, fetcher: async () => 'DB' })).rejects.toThrow('Timeout waiting for cache resolution');
66
+ });
67
+
68
+ it('should handle fetcher failure and release lock', async () => {
69
+ const mockRelease = jest.fn().mockResolvedValue(true);
70
+ const mockRedis = {
71
+ getCache: jest.fn().mockResolvedValue(null),
72
+ acquireLock: jest.fn().mockResolvedValue({ release: mockRelease }),
73
+ };
74
+ const cache = new SmartCacheManager(mockRedis as any);
75
+ await expect(cache.getOrFetch({ userId: 'u5', resourceName: 'profile', ttlSeconds: 60, fetcher: async () => { throw new Error('DB Error'); } })).rejects.toThrow('DB Error');
76
+ expect(mockRelease).toHaveBeenCalled();
77
+ });
78
+
79
+ it('should handle lock release error gracefully', async () => {
80
+ const mockRelease = jest.fn().mockRejectedValue(new Error('Release Error'));
81
+ const mockRedis = {
82
+ getCache: jest.fn().mockResolvedValue(null),
83
+ acquireLock: jest.fn().mockResolvedValue({ release: mockRelease }),
84
+ setCache: jest.fn().mockResolvedValue(true),
85
+ };
86
+ const cache = new SmartCacheManager(mockRedis as any);
87
+ const result = await cache.getOrFetch({ userId: 'u6', resourceName: 'profile', ttlSeconds: 60, fetcher: async () => 'DB' });
88
+ expect(result).toBe('DB');
89
+ expect(mockRelease).toHaveBeenCalled();
90
+ });
91
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../core/tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "baseUrl": ".",
7
+ },
8
+ "include": [
9
+ "src/**/*"
10
+ ]
11
+ }
12
+
13
+
14
+
15
+
16
+
17
+
18
+