@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.
- package/dist/index.d.ts +20 -0
- package/dist/index.js +69 -0
- package/dist/smart-cache.d.ts +23 -0
- package/dist/smart-cache.js +84 -0
- package/package.json +19 -0
- package/src/index.ts +56 -0
- package/src/smart-cache.ts +100 -0
- package/tests/smart-cache.spec.ts +91 -0
- package/tsconfig.json +18 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
});
|