@pioneer-platform/pioneer-cache 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/core/base-cache.d.ts +3 -0
- package/dist/core/base-cache.js +107 -23
- package/dist/core/cache-manager.d.ts +6 -1
- package/dist/core/cache-manager.js +26 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/stores/balance-cache.d.ts +1 -0
- package/dist/stores/balance-cache.js +63 -10
- package/dist/stores/portfolio-cache.d.ts +79 -0
- package/dist/stores/portfolio-cache.js +189 -0
- package/dist/stores/price-cache.d.ts +2 -1
- package/dist/stores/price-cache.js +41 -36
- package/dist/types/index.d.ts +1 -0
- package/package.json +4 -4
- package/src/core/base-cache.ts +121 -23
- package/src/core/cache-manager.ts +34 -2
- package/src/index.ts +2 -0
- package/src/stores/balance-cache.ts +69 -10
- package/src/stores/portfolio-cache.ts +244 -0
- package/src/stores/price-cache.ts +42 -36
- package/src/types/index.ts +1 -0
- package/test/redis-persistence.test.ts +265 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
PortfolioCache - Portfolio/Charts cache implementation
|
|
4
|
+
|
|
5
|
+
Extends BaseCache with portfolio-specific logic.
|
|
6
|
+
Designed for NON-BLOCKING, instant returns.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.PortfolioCache = void 0;
|
|
10
|
+
const base_cache_1 = require("../core/base-cache");
|
|
11
|
+
const log = require('@pioneer-platform/loggerdog')();
|
|
12
|
+
/**
|
|
13
|
+
* PortfolioCache - Caches portfolio/chart data
|
|
14
|
+
*
|
|
15
|
+
* CRITICAL: This cache is NON-BLOCKING by design
|
|
16
|
+
* - Returns empty arrays immediately on cache miss
|
|
17
|
+
* - Never blocks waiting for blockchain APIs
|
|
18
|
+
* - Background jobs populate cache for next request
|
|
19
|
+
*/
|
|
20
|
+
class PortfolioCache extends base_cache_1.BaseCache {
|
|
21
|
+
constructor(redis, balanceModule, marketsModule, config) {
|
|
22
|
+
const defaultConfig = {
|
|
23
|
+
name: 'portfolio',
|
|
24
|
+
keyPrefix: 'portfolio_v2:',
|
|
25
|
+
ttl: 0, // Ignored when enableTTL: false
|
|
26
|
+
staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
|
|
27
|
+
enableTTL: false, // NEVER EXPIRE - data persists forever, show stale data instantly
|
|
28
|
+
queueName: 'cache-refresh',
|
|
29
|
+
enableQueue: true,
|
|
30
|
+
maxRetries: 3,
|
|
31
|
+
retryDelay: 5000,
|
|
32
|
+
blockOnMiss: false, // CRITICAL: NEVER WAIT! Return empty arrays instantly
|
|
33
|
+
enableLegacyFallback: false, // No legacy portfolio cache format
|
|
34
|
+
defaultValue: {
|
|
35
|
+
pubkeys: [],
|
|
36
|
+
charts: [],
|
|
37
|
+
totalValueUsd: 0,
|
|
38
|
+
timestamp: Date.now()
|
|
39
|
+
},
|
|
40
|
+
useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly
|
|
41
|
+
maxConcurrentJobs: 3, // Limit concurrent portfolio refreshes
|
|
42
|
+
apiTimeout: 30000, // 30s timeout for full portfolio fetch
|
|
43
|
+
logCacheHits: true,
|
|
44
|
+
logCacheMisses: true,
|
|
45
|
+
logRefreshJobs: true
|
|
46
|
+
};
|
|
47
|
+
super(redis, { ...defaultConfig, ...config });
|
|
48
|
+
this.balanceModule = balanceModule;
|
|
49
|
+
this.marketsModule = marketsModule;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build Redis key for portfolio data
|
|
53
|
+
*
|
|
54
|
+
* Key strategy: Hash all pubkeys+caips to create a stable identifier
|
|
55
|
+
* Format: portfolio_v2:hash(pubkeys)
|
|
56
|
+
*
|
|
57
|
+
* This allows caching the same portfolio regardless of pubkey order
|
|
58
|
+
*/
|
|
59
|
+
buildKey(params) {
|
|
60
|
+
const { pubkeys } = params;
|
|
61
|
+
if (!pubkeys || !Array.isArray(pubkeys) || pubkeys.length === 0) {
|
|
62
|
+
throw new Error('PortfolioCache.buildKey: pubkeys array required');
|
|
63
|
+
}
|
|
64
|
+
// Sort pubkeys to create stable hash regardless of order
|
|
65
|
+
const sorted = [...pubkeys].sort((a, b) => {
|
|
66
|
+
const aKey = `${a.caip}:${a.pubkey}`;
|
|
67
|
+
const bKey = `${b.caip}:${b.pubkey}`;
|
|
68
|
+
return aKey.localeCompare(bKey);
|
|
69
|
+
});
|
|
70
|
+
// Create a simple hash from sorted pubkeys
|
|
71
|
+
const keyString = sorted.map(p => `${p.caip}:${p.pubkey}`).join('|');
|
|
72
|
+
const hash = this.simpleHash(keyString);
|
|
73
|
+
return `${this.config.keyPrefix}${hash}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Simple hash function for cache keys
|
|
77
|
+
* Not cryptographic - just needs to be stable and collision-resistant
|
|
78
|
+
*/
|
|
79
|
+
simpleHash(str) {
|
|
80
|
+
let hash = 0;
|
|
81
|
+
for (let i = 0; i < str.length; i++) {
|
|
82
|
+
const char = str.charCodeAt(i);
|
|
83
|
+
hash = ((hash << 5) - hash) + char;
|
|
84
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
85
|
+
}
|
|
86
|
+
return Math.abs(hash).toString(36);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Fetch portfolio from blockchain APIs
|
|
90
|
+
*
|
|
91
|
+
* This is the SLOW operation that happens in the background
|
|
92
|
+
* It fetches balances for all pubkeys and enriches with pricing
|
|
93
|
+
*/
|
|
94
|
+
async fetchFromSource(params) {
|
|
95
|
+
const tag = this.TAG + 'fetchFromSource | ';
|
|
96
|
+
const startTime = Date.now();
|
|
97
|
+
try {
|
|
98
|
+
const { pubkeys } = params;
|
|
99
|
+
log.info(tag, `Fetching portfolio for ${pubkeys.length} pubkeys`);
|
|
100
|
+
const charts = [];
|
|
101
|
+
// Fetch balances for all pubkeys in parallel
|
|
102
|
+
const balancePromises = pubkeys.map(async (item) => {
|
|
103
|
+
try {
|
|
104
|
+
// Extract networkId from CAIP
|
|
105
|
+
const networkId = item.caip.split('/')[0];
|
|
106
|
+
// Fetch balance
|
|
107
|
+
const asset = { caip: item.caip };
|
|
108
|
+
const owner = { pubkey: item.pubkey };
|
|
109
|
+
const balanceInfo = await this.balanceModule.getBalance(asset, owner);
|
|
110
|
+
if (!balanceInfo || !balanceInfo.balance) {
|
|
111
|
+
log.debug(tag, `No balance for ${item.caip}/${item.pubkey.substring(0, 10)}...`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// Skip zero balances
|
|
115
|
+
const balanceNum = parseFloat(balanceInfo.balance);
|
|
116
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Get asset metadata
|
|
120
|
+
const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
|
|
121
|
+
const assetInfo = assetData[item.caip.toUpperCase()] || assetData[item.caip.toLowerCase()] || {};
|
|
122
|
+
// Get price
|
|
123
|
+
let priceUsd = 0;
|
|
124
|
+
try {
|
|
125
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(item.caip);
|
|
126
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
127
|
+
priceUsd = 0;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (priceError) {
|
|
131
|
+
log.warn(tag, `Error fetching price for ${item.caip}:`, priceError);
|
|
132
|
+
}
|
|
133
|
+
const valueUsd = balanceNum * priceUsd;
|
|
134
|
+
const chartData = {
|
|
135
|
+
caip: item.caip,
|
|
136
|
+
pubkey: item.pubkey,
|
|
137
|
+
networkId,
|
|
138
|
+
symbol: assetInfo.symbol || 'UNKNOWN',
|
|
139
|
+
name: assetInfo.name || 'Unknown Asset',
|
|
140
|
+
balance: balanceInfo.balance,
|
|
141
|
+
priceUsd,
|
|
142
|
+
valueUsd,
|
|
143
|
+
icon: assetInfo.icon || '',
|
|
144
|
+
type: assetInfo.type || 'native',
|
|
145
|
+
decimal: assetInfo.decimal
|
|
146
|
+
};
|
|
147
|
+
return chartData;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
log.error(tag, `Error fetching balance for ${item.caip}/${item.pubkey}:`, error);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const results = await Promise.all(balancePromises);
|
|
155
|
+
// Filter out nulls and calculate total
|
|
156
|
+
const validCharts = results.filter((c) => c !== null);
|
|
157
|
+
const totalValueUsd = validCharts.reduce((sum, c) => sum + c.valueUsd, 0);
|
|
158
|
+
const fetchTime = Date.now() - startTime;
|
|
159
|
+
log.info(tag, `✅ Fetched portfolio: ${validCharts.length} assets, $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
|
|
160
|
+
return {
|
|
161
|
+
pubkeys,
|
|
162
|
+
charts: validCharts,
|
|
163
|
+
totalValueUsd,
|
|
164
|
+
timestamp: Date.now()
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
log.error(tag, 'Error fetching portfolio:', error);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* No legacy cache format for portfolios
|
|
174
|
+
*/
|
|
175
|
+
async getLegacyCached(params) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get portfolio for a set of pubkeys
|
|
180
|
+
* Convenience method that wraps base get()
|
|
181
|
+
*
|
|
182
|
+
* RETURNS INSTANTLY - either cached data or empty arrays
|
|
183
|
+
*/
|
|
184
|
+
async getPortfolio(pubkeys, waitForFresh) {
|
|
185
|
+
const result = await this.get({ pubkeys }, waitForFresh);
|
|
186
|
+
return result.value || this.config.defaultValue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
exports.PortfolioCache = PortfolioCache;
|
|
@@ -20,7 +20,8 @@ export declare class PriceCache extends BaseCache<PriceData> {
|
|
|
20
20
|
*/
|
|
21
21
|
protected buildKey(params: Record<string, any>): string;
|
|
22
22
|
/**
|
|
23
|
-
* Fetch price from markets API
|
|
23
|
+
* Fetch price from markets API using CAIP-first approach
|
|
24
|
+
* FIX #7: Graceful handling of zero prices to prevent cache disruption
|
|
24
25
|
*/
|
|
25
26
|
protected fetchFromSource(params: Record<string, any>): Promise<PriceData>;
|
|
26
27
|
/**
|
|
@@ -17,21 +17,22 @@ class PriceCache extends base_cache_1.BaseCache {
|
|
|
17
17
|
const defaultConfig = {
|
|
18
18
|
name: 'price',
|
|
19
19
|
keyPrefix: 'price_v2:',
|
|
20
|
-
ttl:
|
|
21
|
-
staleThreshold: 30 * 60 * 1000, // 30 minutes
|
|
22
|
-
enableTTL:
|
|
23
|
-
queueName: '
|
|
20
|
+
ttl: 0, // Ignored when enableTTL: false
|
|
21
|
+
staleThreshold: 30 * 60 * 1000, // 30 minutes - triggers background refresh
|
|
22
|
+
enableTTL: false, // NEVER EXPIRE - data persists forever, show stale prices instantly
|
|
23
|
+
queueName: 'cache-refresh',
|
|
24
24
|
enableQueue: true,
|
|
25
25
|
maxRetries: 3,
|
|
26
26
|
retryDelay: 5000,
|
|
27
|
-
blockOnMiss: false, //
|
|
27
|
+
blockOnMiss: false, // CRITICAL: NEVER WAIT! Return $0 instantly on cache miss, refresh async in background
|
|
28
28
|
enableLegacyFallback: true,
|
|
29
29
|
defaultValue: {
|
|
30
30
|
caip: '',
|
|
31
31
|
price: 0
|
|
32
32
|
},
|
|
33
|
+
useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly with $0
|
|
33
34
|
maxConcurrentJobs: 5,
|
|
34
|
-
apiTimeout:
|
|
35
|
+
apiTimeout: 2000, // Reduced from 5000ms for faster failures
|
|
35
36
|
logCacheHits: false,
|
|
36
37
|
logCacheMisses: true,
|
|
37
38
|
logRefreshJobs: true
|
|
@@ -52,49 +53,53 @@ class PriceCache extends base_cache_1.BaseCache {
|
|
|
52
53
|
return `${this.config.keyPrefix}${normalizedCaip}`;
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
|
-
* Fetch price from markets API
|
|
56
|
+
* Fetch price from markets API using CAIP-first approach
|
|
57
|
+
* FIX #7: Graceful handling of zero prices to prevent cache disruption
|
|
56
58
|
*/
|
|
57
59
|
async fetchFromSource(params) {
|
|
58
60
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
59
61
|
try {
|
|
60
62
|
const { caip } = params;
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
caip,
|
|
68
|
-
price: 0
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
// Get price from markets module
|
|
72
|
-
const symbol = asset.symbol.toLowerCase();
|
|
73
|
-
const priceResult = await this.markets.getPrice(symbol);
|
|
74
|
-
// Handle different response formats
|
|
75
|
-
let price = 0;
|
|
76
|
-
if (typeof priceResult === 'object' && priceResult.price) {
|
|
77
|
-
price = parseFloat(priceResult.price);
|
|
78
|
-
}
|
|
79
|
-
else if (typeof priceResult === 'number') {
|
|
80
|
-
price = priceResult;
|
|
81
|
-
}
|
|
63
|
+
// Use CAIP-first API (no symbol conversion needed!)
|
|
64
|
+
// This directly queries the markets module with CAIP identifiers
|
|
65
|
+
const price = await this.markets.getAssetPriceByCaip(caip);
|
|
66
|
+
// FIX #7: Gracefully handle zero prices without throwing
|
|
67
|
+
// This prevents disrupting batch operations during API rate limits
|
|
82
68
|
if (isNaN(price) || price <= 0) {
|
|
83
|
-
log.warn(tag, `
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
69
|
+
log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
|
|
70
|
+
// Try to get stale cached value instead of failing
|
|
71
|
+
const key = this.buildKey(params);
|
|
72
|
+
const cachedValue = await this.getCached(key);
|
|
73
|
+
if (cachedValue && cachedValue.value.price > 0) {
|
|
74
|
+
log.info(tag, `Returning stale cached price for ${caip}: $${cachedValue.value.price}`);
|
|
75
|
+
return cachedValue.value;
|
|
76
|
+
}
|
|
77
|
+
// Try legacy cache as fallback
|
|
78
|
+
const legacyValue = await this.getLegacyCached(params);
|
|
79
|
+
if (legacyValue && legacyValue.price > 0) {
|
|
80
|
+
log.info(tag, `Returning legacy cached price for ${caip}: $${legacyValue.price}`);
|
|
81
|
+
return legacyValue;
|
|
82
|
+
}
|
|
83
|
+
// Last resort: return zero price but don't cache it
|
|
84
|
+
log.warn(tag, `No cached price available for ${caip}, returning zero`);
|
|
85
|
+
throw new Error(`No valid price available for ${caip}`);
|
|
88
86
|
}
|
|
89
|
-
log.debug(tag, `Fetched price for ${caip}
|
|
87
|
+
log.debug(tag, `Fetched price for ${caip}: $${price}`);
|
|
90
88
|
return {
|
|
91
89
|
caip,
|
|
92
90
|
price,
|
|
93
|
-
source: 'markets'
|
|
91
|
+
source: 'markets-caip'
|
|
94
92
|
};
|
|
95
93
|
}
|
|
96
94
|
catch (error) {
|
|
97
|
-
|
|
95
|
+
// Log as warning instead of error for expected API issues
|
|
96
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
97
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid price')) {
|
|
98
|
+
log.warn(tag, `Expected API issue: ${errorMsg}`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
log.error(tag, `Unexpected error fetching price:`, error);
|
|
102
|
+
}
|
|
98
103
|
throw error;
|
|
99
104
|
}
|
|
100
105
|
}
|
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pioneer-platform/pioneer-cache",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Unified caching system for Pioneer platform with Redis backend",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"author": "Pioneer Platform",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@pioneer-platform/loggerdog": "
|
|
24
|
-
"@pioneer-platform/redis-queue": "
|
|
25
|
-
"@pioneer-platform/default-redis": "
|
|
23
|
+
"@pioneer-platform/loggerdog": "^8.0.0",
|
|
24
|
+
"@pioneer-platform/redis-queue": "^8.0.0",
|
|
25
|
+
"@pioneer-platform/default-redis": "^8.0.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^20.0.0",
|
package/src/core/base-cache.ts
CHANGED
|
@@ -31,6 +31,10 @@ export abstract class BaseCache<T> {
|
|
|
31
31
|
private cachedStatsTimestamp: number = 0;
|
|
32
32
|
private readonly STATS_CACHE_TTL = 30000; // 30 seconds
|
|
33
33
|
|
|
34
|
+
// FIX #6: Request deduplication to prevent thundering herd
|
|
35
|
+
// Tracks in-flight network requests to prevent duplicate API calls
|
|
36
|
+
private pendingFetches: Map<string, Promise<T>> = new Map();
|
|
37
|
+
|
|
34
38
|
constructor(redis: any, config: CacheConfig) {
|
|
35
39
|
this.redis = redis;
|
|
36
40
|
this.config = config;
|
|
@@ -73,10 +77,14 @@ export abstract class BaseCache<T> {
|
|
|
73
77
|
const startTime = Date.now();
|
|
74
78
|
|
|
75
79
|
try {
|
|
80
|
+
const t1 = Date.now();
|
|
76
81
|
const key = this.buildKey(params);
|
|
82
|
+
log.info(tag, `⏱️ buildKey took ${Date.now() - t1}ms`);
|
|
77
83
|
|
|
78
84
|
// Step 1: Try new cache format
|
|
85
|
+
const t2 = Date.now();
|
|
79
86
|
const cachedValue = await this.getCached(key);
|
|
87
|
+
log.info(tag, `⏱️ getCached took ${Date.now() - t2}ms`);
|
|
80
88
|
|
|
81
89
|
if (cachedValue) {
|
|
82
90
|
const age = Date.now() - cachedValue.timestamp;
|
|
@@ -102,7 +110,9 @@ export abstract class BaseCache<T> {
|
|
|
102
110
|
|
|
103
111
|
// Step 2: Try legacy cache fallback
|
|
104
112
|
if (this.config.enableLegacyFallback) {
|
|
113
|
+
const t3 = Date.now();
|
|
105
114
|
const legacyValue = await this.getLegacyCached(params);
|
|
115
|
+
log.info(tag, `⏱️ getLegacyCached took ${Date.now() - t3}ms`);
|
|
106
116
|
if (legacyValue) {
|
|
107
117
|
const responseTime = Date.now() - startTime;
|
|
108
118
|
log.info(tag, `Legacy cache hit: ${key} (${responseTime}ms)`);
|
|
@@ -141,7 +151,10 @@ export abstract class BaseCache<T> {
|
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
// Non-blocking: trigger async refresh and return default
|
|
154
|
+
const t4 = Date.now();
|
|
144
155
|
this.triggerAsyncRefresh(params, 'high');
|
|
156
|
+
log.info(tag, `⏱️ triggerAsyncRefresh took ${Date.now() - t4}ms`);
|
|
157
|
+
log.info(tag, `⏱️ Returning default value after ${Date.now() - startTime}ms TOTAL`);
|
|
145
158
|
|
|
146
159
|
return {
|
|
147
160
|
success: true,
|
|
@@ -168,22 +181,37 @@ export abstract class BaseCache<T> {
|
|
|
168
181
|
*/
|
|
169
182
|
protected async getCached(key: string): Promise<CachedValue<T> | null> {
|
|
170
183
|
const tag = this.TAG + 'getCached | ';
|
|
184
|
+
const t0 = Date.now();
|
|
171
185
|
|
|
172
186
|
try {
|
|
173
|
-
|
|
187
|
+
// Redis timeout for cache reads
|
|
188
|
+
// Increased from 100ms to 2000ms - 100ms was too aggressive and caused false cache misses
|
|
189
|
+
// Increased from 2000ms to 10000ms - IPv4/IPv6 DNS resolution can cause delays
|
|
190
|
+
const timeoutMs = 10000;
|
|
191
|
+
const cached = await Promise.race([
|
|
192
|
+
this.redis.get(key),
|
|
193
|
+
new Promise<null>((resolve) => setTimeout(() => {
|
|
194
|
+
log.warn(tag, `⏱️ Redis timeout after ${timeoutMs}ms, returning cache miss`);
|
|
195
|
+
resolve(null);
|
|
196
|
+
}, timeoutMs))
|
|
197
|
+
]);
|
|
198
|
+
|
|
174
199
|
if (!cached) {
|
|
200
|
+
log.debug(tag, `Cache miss: ${key}`);
|
|
175
201
|
return null;
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
const parsed: CachedValue<T> = JSON.parse(cached);
|
|
179
205
|
|
|
180
|
-
// Validate structure
|
|
181
|
-
|
|
206
|
+
// Validate structure - Check for undefined/null, NOT falsy values!
|
|
207
|
+
// CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
|
|
208
|
+
if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
|
|
182
209
|
log.warn(tag, `Invalid cache structure for ${key}, removing`);
|
|
183
210
|
await this.redis.del(key);
|
|
184
211
|
return null;
|
|
185
212
|
}
|
|
186
213
|
|
|
214
|
+
log.debug(tag, `Cache hit: ${key}`);
|
|
187
215
|
return parsed;
|
|
188
216
|
|
|
189
217
|
} catch (error) {
|
|
@@ -208,19 +236,33 @@ export abstract class BaseCache<T> {
|
|
|
208
236
|
metadata
|
|
209
237
|
};
|
|
210
238
|
|
|
239
|
+
// DIAGNOSTIC: Log Redis connection details
|
|
240
|
+
const redisConstructor = this.redis.constructor?.name || 'unknown';
|
|
241
|
+
const redisOptions = this.redis.options || {};
|
|
242
|
+
|
|
211
243
|
// FIX #2: Always set TTL (unless explicitly disabled)
|
|
212
244
|
if (this.config.enableTTL) {
|
|
213
245
|
const ttlSeconds = Math.floor(this.config.ttl / 1000);
|
|
246
|
+
|
|
247
|
+
// PERF: Reduced logging for production performance
|
|
248
|
+
log.debug(tag, `📝 Writing to Redis: ${key} [${JSON.stringify(cachedValue).length} bytes, TTL: ${ttlSeconds}s]`);
|
|
249
|
+
|
|
250
|
+
// Write
|
|
214
251
|
await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
|
|
215
|
-
|
|
252
|
+
|
|
253
|
+
// PERF: Verification disabled for production performance
|
|
254
|
+
// Only enable for debugging cache issues
|
|
255
|
+
log.debug(tag, `✅ Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
|
|
216
256
|
} else {
|
|
217
257
|
// Permanent caching (for transactions)
|
|
218
258
|
await this.redis.set(key, JSON.stringify(cachedValue));
|
|
219
|
-
|
|
259
|
+
|
|
260
|
+
// PERF: Verification disabled for production performance
|
|
261
|
+
log.debug(tag, `✅ Updated cache: ${key} [PERMANENT]`);
|
|
220
262
|
}
|
|
221
263
|
|
|
222
264
|
} catch (error) {
|
|
223
|
-
log.error(tag,
|
|
265
|
+
log.error(tag, `❌ Error updating cache for ${key}:`, error);
|
|
224
266
|
throw error;
|
|
225
267
|
}
|
|
226
268
|
}
|
|
@@ -245,8 +287,13 @@ export abstract class BaseCache<T> {
|
|
|
245
287
|
log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
|
|
246
288
|
log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
|
|
247
289
|
|
|
248
|
-
// FIX #4: Synchronous fallback for high-priority
|
|
249
|
-
|
|
290
|
+
// FIX #4: Synchronous fallback for high-priority (only if useSyncFallback is enabled)
|
|
291
|
+
// Default: use sync fallback only for blocking caches (blockOnMiss=true)
|
|
292
|
+
const shouldUseSyncFallback = this.config.useSyncFallback !== undefined
|
|
293
|
+
? this.config.useSyncFallback
|
|
294
|
+
: this.config.blockOnMiss;
|
|
295
|
+
|
|
296
|
+
if (priority === 'high' && shouldUseSyncFallback) {
|
|
250
297
|
log.warn(tag, `Using synchronous fallback for high-priority refresh`);
|
|
251
298
|
setImmediate(async () => {
|
|
252
299
|
try {
|
|
@@ -290,31 +337,82 @@ export abstract class BaseCache<T> {
|
|
|
290
337
|
/**
|
|
291
338
|
* Fetch fresh data and update cache
|
|
292
339
|
* FIX #1 & #4: Used for blocking requests and fallback
|
|
340
|
+
* FIX #6: Request deduplication to prevent thundering herd
|
|
341
|
+
* FIX #8: Return stale cache on fetch failures
|
|
293
342
|
*/
|
|
294
343
|
async fetchFresh(params: Record<string, any>): Promise<T> {
|
|
295
344
|
const tag = this.TAG + 'fetchFresh | ';
|
|
296
345
|
const startTime = Date.now();
|
|
346
|
+
const key = this.buildKey(params);
|
|
347
|
+
|
|
348
|
+
// FIX #6: Check if there's already a pending fetch for this key
|
|
349
|
+
const existingFetch = this.pendingFetches.get(key);
|
|
350
|
+
if (existingFetch) {
|
|
351
|
+
// For non-blocking caches, return default value immediately instead of waiting
|
|
352
|
+
if (!this.config.blockOnMiss) {
|
|
353
|
+
log.debug(tag, `Non-blocking cache: returning default value while fetch in progress: ${key}`);
|
|
354
|
+
return this.config.defaultValue;
|
|
355
|
+
}
|
|
297
356
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
357
|
+
// For blocking caches, coalesce to prevent thundering herd
|
|
358
|
+
log.debug(tag, `Coalescing request for: ${key} (fetch already in progress)`);
|
|
359
|
+
return existingFetch;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Create the fetch promise
|
|
363
|
+
const fetchPromise = (async () => {
|
|
364
|
+
try {
|
|
365
|
+
log.info(tag, `Fetching fresh data: ${key}`);
|
|
301
366
|
|
|
302
|
-
|
|
303
|
-
|
|
367
|
+
// Call subclass-specific fetch implementation
|
|
368
|
+
const value = await this.fetchFromSource(params);
|
|
304
369
|
|
|
305
|
-
|
|
306
|
-
|
|
370
|
+
// Update cache
|
|
371
|
+
await this.updateCache(key, value);
|
|
307
372
|
|
|
308
|
-
|
|
309
|
-
|
|
373
|
+
const fetchTime = Date.now() - startTime;
|
|
374
|
+
log.info(tag, `✅ Fetched fresh data in ${fetchTime}ms: ${key}`);
|
|
310
375
|
|
|
311
|
-
|
|
376
|
+
return value;
|
|
312
377
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const fetchTime = Date.now() - startTime;
|
|
380
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
381
|
+
|
|
382
|
+
// FIX #8: Try to return stale cache on fetch failures
|
|
383
|
+
const cachedValue = await this.getCached(key);
|
|
384
|
+
if (cachedValue) {
|
|
385
|
+
log.warn(tag, `Fetch failed after ${fetchTime}ms, returning stale cache: ${key}`);
|
|
386
|
+
return cachedValue.value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Try legacy cache as last resort
|
|
390
|
+
if (this.config.enableLegacyFallback) {
|
|
391
|
+
const legacyValue = await this.getLegacyCached(params);
|
|
392
|
+
if (legacyValue) {
|
|
393
|
+
log.warn(tag, `Fetch failed after ${fetchTime}ms, returning legacy cache: ${key}`);
|
|
394
|
+
return legacyValue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Log as warning for expected issues, error for unexpected
|
|
399
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid')) {
|
|
400
|
+
log.warn(tag, `Expected fetch failure after ${fetchTime}ms: ${errorMsg}`);
|
|
401
|
+
} else {
|
|
402
|
+
log.error(tag, `Unexpected fetch failure after ${fetchTime}ms:`, error);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return this.config.defaultValue;
|
|
406
|
+
} finally {
|
|
407
|
+
// Clean up pending fetch
|
|
408
|
+
this.pendingFetches.delete(key);
|
|
409
|
+
}
|
|
410
|
+
})();
|
|
411
|
+
|
|
412
|
+
// Store the promise so concurrent requests can reuse it
|
|
413
|
+
this.pendingFetches.set(key, fetchPromise);
|
|
414
|
+
|
|
415
|
+
return fetchPromise;
|
|
318
416
|
}
|
|
319
417
|
|
|
320
418
|
/**
|