@pioneer-platform/pioneer-cache 1.0.1 → 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/.turbo/turbo-build.log +2 -1
- package/CHANGELOG.md +6 -0
- package/dist/core/base-cache.d.ts +1 -0
- package/dist/core/base-cache.js +73 -9
- 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 +1 -1
- package/dist/stores/price-cache.js +35 -15
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/core/base-cache.ts +84 -9
- 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 +38 -15
- 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;
|
|
@@ -21,7 +21,7 @@ export declare class PriceCache extends BaseCache<PriceData> {
|
|
|
21
21
|
protected buildKey(params: Record<string, any>): string;
|
|
22
22
|
/**
|
|
23
23
|
* Fetch price from markets API using CAIP-first approach
|
|
24
|
-
* FIX #7:
|
|
24
|
+
* FIX #7: Graceful handling of zero prices to prevent cache disruption
|
|
25
25
|
*/
|
|
26
26
|
protected fetchFromSource(params: Record<string, any>): Promise<PriceData>;
|
|
27
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:
|
|
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
|
|
@@ -53,7 +54,7 @@ class PriceCache extends base_cache_1.BaseCache {
|
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
56
|
* Fetch price from markets API using CAIP-first approach
|
|
56
|
-
* FIX #7:
|
|
57
|
+
* FIX #7: Graceful handling of zero prices to prevent cache disruption
|
|
57
58
|
*/
|
|
58
59
|
async fetchFromSource(params) {
|
|
59
60
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
@@ -62,12 +63,26 @@ class PriceCache extends base_cache_1.BaseCache {
|
|
|
62
63
|
// Use CAIP-first API (no symbol conversion needed!)
|
|
63
64
|
// This directly queries the markets module with CAIP identifiers
|
|
64
65
|
const price = await this.markets.getAssetPriceByCaip(caip);
|
|
65
|
-
// FIX #7:
|
|
66
|
-
// This prevents
|
|
66
|
+
// FIX #7: Gracefully handle zero prices without throwing
|
|
67
|
+
// This prevents disrupting batch operations during API rate limits
|
|
67
68
|
if (isNaN(price) || price <= 0) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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}`);
|
|
71
86
|
}
|
|
72
87
|
log.debug(tag, `Fetched price for ${caip}: $${price}`);
|
|
73
88
|
return {
|
|
@@ -77,9 +92,14 @@ class PriceCache extends base_cache_1.BaseCache {
|
|
|
77
92
|
};
|
|
78
93
|
}
|
|
79
94
|
catch (error) {
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
83
103
|
throw error;
|
|
84
104
|
}
|
|
85
105
|
}
|
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/core/base-cache.ts
CHANGED
|
@@ -77,10 +77,14 @@ export abstract class BaseCache<T> {
|
|
|
77
77
|
const startTime = Date.now();
|
|
78
78
|
|
|
79
79
|
try {
|
|
80
|
+
const t1 = Date.now();
|
|
80
81
|
const key = this.buildKey(params);
|
|
82
|
+
log.info(tag, `⏱️ buildKey took ${Date.now() - t1}ms`);
|
|
81
83
|
|
|
82
84
|
// Step 1: Try new cache format
|
|
85
|
+
const t2 = Date.now();
|
|
83
86
|
const cachedValue = await this.getCached(key);
|
|
87
|
+
log.info(tag, `⏱️ getCached took ${Date.now() - t2}ms`);
|
|
84
88
|
|
|
85
89
|
if (cachedValue) {
|
|
86
90
|
const age = Date.now() - cachedValue.timestamp;
|
|
@@ -106,7 +110,9 @@ export abstract class BaseCache<T> {
|
|
|
106
110
|
|
|
107
111
|
// Step 2: Try legacy cache fallback
|
|
108
112
|
if (this.config.enableLegacyFallback) {
|
|
113
|
+
const t3 = Date.now();
|
|
109
114
|
const legacyValue = await this.getLegacyCached(params);
|
|
115
|
+
log.info(tag, `⏱️ getLegacyCached took ${Date.now() - t3}ms`);
|
|
110
116
|
if (legacyValue) {
|
|
111
117
|
const responseTime = Date.now() - startTime;
|
|
112
118
|
log.info(tag, `Legacy cache hit: ${key} (${responseTime}ms)`);
|
|
@@ -145,7 +151,10 @@ export abstract class BaseCache<T> {
|
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
// Non-blocking: trigger async refresh and return default
|
|
154
|
+
const t4 = Date.now();
|
|
148
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`);
|
|
149
158
|
|
|
150
159
|
return {
|
|
151
160
|
success: true,
|
|
@@ -172,22 +181,37 @@ export abstract class BaseCache<T> {
|
|
|
172
181
|
*/
|
|
173
182
|
protected async getCached(key: string): Promise<CachedValue<T> | null> {
|
|
174
183
|
const tag = this.TAG + 'getCached | ';
|
|
184
|
+
const t0 = Date.now();
|
|
175
185
|
|
|
176
186
|
try {
|
|
177
|
-
|
|
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
|
+
|
|
178
199
|
if (!cached) {
|
|
200
|
+
log.debug(tag, `Cache miss: ${key}`);
|
|
179
201
|
return null;
|
|
180
202
|
}
|
|
181
203
|
|
|
182
204
|
const parsed: CachedValue<T> = JSON.parse(cached);
|
|
183
205
|
|
|
184
|
-
// Validate structure
|
|
185
|
-
|
|
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') {
|
|
186
209
|
log.warn(tag, `Invalid cache structure for ${key}, removing`);
|
|
187
210
|
await this.redis.del(key);
|
|
188
211
|
return null;
|
|
189
212
|
}
|
|
190
213
|
|
|
214
|
+
log.debug(tag, `Cache hit: ${key}`);
|
|
191
215
|
return parsed;
|
|
192
216
|
|
|
193
217
|
} catch (error) {
|
|
@@ -212,19 +236,33 @@ export abstract class BaseCache<T> {
|
|
|
212
236
|
metadata
|
|
213
237
|
};
|
|
214
238
|
|
|
239
|
+
// DIAGNOSTIC: Log Redis connection details
|
|
240
|
+
const redisConstructor = this.redis.constructor?.name || 'unknown';
|
|
241
|
+
const redisOptions = this.redis.options || {};
|
|
242
|
+
|
|
215
243
|
// FIX #2: Always set TTL (unless explicitly disabled)
|
|
216
244
|
if (this.config.enableTTL) {
|
|
217
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
|
|
218
251
|
await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
|
|
219
|
-
|
|
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]`);
|
|
220
256
|
} else {
|
|
221
257
|
// Permanent caching (for transactions)
|
|
222
258
|
await this.redis.set(key, JSON.stringify(cachedValue));
|
|
223
|
-
|
|
259
|
+
|
|
260
|
+
// PERF: Verification disabled for production performance
|
|
261
|
+
log.debug(tag, `✅ Updated cache: ${key} [PERMANENT]`);
|
|
224
262
|
}
|
|
225
263
|
|
|
226
264
|
} catch (error) {
|
|
227
|
-
log.error(tag,
|
|
265
|
+
log.error(tag, `❌ Error updating cache for ${key}:`, error);
|
|
228
266
|
throw error;
|
|
229
267
|
}
|
|
230
268
|
}
|
|
@@ -249,8 +287,13 @@ export abstract class BaseCache<T> {
|
|
|
249
287
|
log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
|
|
250
288
|
log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
|
|
251
289
|
|
|
252
|
-
// FIX #4: Synchronous fallback for high-priority
|
|
253
|
-
|
|
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) {
|
|
254
297
|
log.warn(tag, `Using synchronous fallback for high-priority refresh`);
|
|
255
298
|
setImmediate(async () => {
|
|
256
299
|
try {
|
|
@@ -295,6 +338,7 @@ export abstract class BaseCache<T> {
|
|
|
295
338
|
* Fetch fresh data and update cache
|
|
296
339
|
* FIX #1 & #4: Used for blocking requests and fallback
|
|
297
340
|
* FIX #6: Request deduplication to prevent thundering herd
|
|
341
|
+
* FIX #8: Return stale cache on fetch failures
|
|
298
342
|
*/
|
|
299
343
|
async fetchFresh(params: Record<string, any>): Promise<T> {
|
|
300
344
|
const tag = this.TAG + 'fetchFresh | ';
|
|
@@ -304,6 +348,13 @@ export abstract class BaseCache<T> {
|
|
|
304
348
|
// FIX #6: Check if there's already a pending fetch for this key
|
|
305
349
|
const existingFetch = this.pendingFetches.get(key);
|
|
306
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
|
+
}
|
|
356
|
+
|
|
357
|
+
// For blocking caches, coalesce to prevent thundering herd
|
|
307
358
|
log.debug(tag, `Coalescing request for: ${key} (fetch already in progress)`);
|
|
308
359
|
return existingFetch;
|
|
309
360
|
}
|
|
@@ -326,7 +377,31 @@ export abstract class BaseCache<T> {
|
|
|
326
377
|
|
|
327
378
|
} catch (error) {
|
|
328
379
|
const fetchTime = Date.now() - startTime;
|
|
329
|
-
|
|
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
|
+
|
|
330
405
|
return this.config.defaultValue;
|
|
331
406
|
} finally {
|
|
332
407
|
// Clean up pending fetch
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { BalanceCache } from '../stores/balance-cache';
|
|
9
9
|
import { PriceCache } from '../stores/price-cache';
|
|
10
|
+
import { PortfolioCache } from '../stores/portfolio-cache';
|
|
10
11
|
import { TransactionCache } from '../stores/transaction-cache';
|
|
11
12
|
import { RefreshWorker, startUnifiedWorker } from '../workers/refresh-worker';
|
|
12
13
|
import type { BaseCache } from './base-cache';
|
|
@@ -24,6 +25,7 @@ export interface CacheManagerConfig {
|
|
|
24
25
|
markets?: any; // Optional: if not provided, price cache won't be initialized
|
|
25
26
|
enableBalanceCache?: boolean;
|
|
26
27
|
enablePriceCache?: boolean;
|
|
28
|
+
enablePortfolioCache?: boolean;
|
|
27
29
|
enableTransactionCache?: boolean;
|
|
28
30
|
startWorkers?: boolean; // Auto-start workers on initialization
|
|
29
31
|
}
|
|
@@ -35,6 +37,7 @@ export class CacheManager {
|
|
|
35
37
|
private redis: any;
|
|
36
38
|
private balanceCache?: BalanceCache;
|
|
37
39
|
private priceCache?: PriceCache;
|
|
40
|
+
private portfolioCache?: PortfolioCache;
|
|
38
41
|
private transactionCache?: TransactionCache;
|
|
39
42
|
private workers: RefreshWorker[] = [];
|
|
40
43
|
|
|
@@ -53,6 +56,12 @@ export class CacheManager {
|
|
|
53
56
|
log.info(TAG, '✅ Price cache initialized');
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// Initialize Portfolio Cache
|
|
60
|
+
if (config.enablePortfolioCache !== false && config.balanceModule && config.markets) {
|
|
61
|
+
this.portfolioCache = new PortfolioCache(this.redis, config.balanceModule, config.markets);
|
|
62
|
+
log.info(TAG, '✅ Portfolio cache initialized');
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
// Initialize Transaction Cache
|
|
57
66
|
if (config.enableTransactionCache !== false) {
|
|
58
67
|
this.transactionCache = new TransactionCache(this.redis);
|
|
@@ -89,6 +98,10 @@ export class CacheManager {
|
|
|
89
98
|
cacheRegistry.set('price', this.priceCache);
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
if (this.portfolioCache) {
|
|
102
|
+
cacheRegistry.set('portfolio', this.portfolioCache);
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
// Start unified worker if we have any caches with queues
|
|
93
106
|
if (cacheRegistry.size > 0) {
|
|
94
107
|
const worker = await startUnifiedWorker(
|
|
@@ -170,6 +183,18 @@ export class CacheManager {
|
|
|
170
183
|
}
|
|
171
184
|
}
|
|
172
185
|
|
|
186
|
+
// Check portfolio cache
|
|
187
|
+
if (this.portfolioCache) {
|
|
188
|
+
const portfolioHealth = await this.portfolioCache.getHealth(forceRefresh);
|
|
189
|
+
checks.portfolio = portfolioHealth;
|
|
190
|
+
|
|
191
|
+
if (portfolioHealth.status === 'unhealthy') {
|
|
192
|
+
issues.push(...portfolioHealth.issues.map(i => `Portfolio: ${i}`));
|
|
193
|
+
} else if (portfolioHealth.status === 'degraded') {
|
|
194
|
+
warnings.push(...portfolioHealth.warnings.map(w => `Portfolio: ${w}`));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
// Check transaction cache (simple stats check)
|
|
174
199
|
if (this.transactionCache) {
|
|
175
200
|
const txStats = await this.transactionCache.getStats();
|
|
@@ -241,6 +266,7 @@ export class CacheManager {
|
|
|
241
266
|
return {
|
|
242
267
|
balance: this.balanceCache,
|
|
243
268
|
price: this.priceCache,
|
|
269
|
+
portfolio: this.portfolioCache,
|
|
244
270
|
transaction: this.transactionCache
|
|
245
271
|
};
|
|
246
272
|
}
|
|
@@ -248,12 +274,14 @@ export class CacheManager {
|
|
|
248
274
|
/**
|
|
249
275
|
* Get specific cache by name
|
|
250
276
|
*/
|
|
251
|
-
getCache(name: 'balance' | 'price' | 'transaction') {
|
|
277
|
+
getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction') {
|
|
252
278
|
switch (name) {
|
|
253
279
|
case 'balance':
|
|
254
280
|
return this.balanceCache;
|
|
255
281
|
case 'price':
|
|
256
282
|
return this.priceCache;
|
|
283
|
+
case 'portfolio':
|
|
284
|
+
return this.portfolioCache;
|
|
257
285
|
case 'transaction':
|
|
258
286
|
return this.transactionCache;
|
|
259
287
|
default:
|
|
@@ -264,7 +292,7 @@ export class CacheManager {
|
|
|
264
292
|
/**
|
|
265
293
|
* Clear all caches (use with caution!)
|
|
266
294
|
*/
|
|
267
|
-
async clearAll(): Promise<{ balance?: number; price?: number; transaction?: number }> {
|
|
295
|
+
async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number }> {
|
|
268
296
|
const tag = TAG + 'clearAll | ';
|
|
269
297
|
|
|
270
298
|
try {
|
|
@@ -278,6 +306,10 @@ export class CacheManager {
|
|
|
278
306
|
result.price = await this.priceCache.clearAll();
|
|
279
307
|
}
|
|
280
308
|
|
|
309
|
+
if (this.portfolioCache) {
|
|
310
|
+
result.portfolio = await this.portfolioCache.clearAll();
|
|
311
|
+
}
|
|
312
|
+
|
|
281
313
|
if (this.transactionCache) {
|
|
282
314
|
result.transaction = await this.transactionCache.clearAll();
|
|
283
315
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { CacheManager } from './core/cache-manager';
|
|
|
12
12
|
// Cache implementations
|
|
13
13
|
export { BalanceCache } from './stores/balance-cache';
|
|
14
14
|
export { PriceCache } from './stores/price-cache';
|
|
15
|
+
export { PortfolioCache } from './stores/portfolio-cache';
|
|
15
16
|
export { TransactionCache } from './stores/transaction-cache';
|
|
16
17
|
|
|
17
18
|
// Worker exports
|
|
@@ -30,6 +31,7 @@ export type {
|
|
|
30
31
|
// Data type exports
|
|
31
32
|
export type { BalanceData } from './stores/balance-cache';
|
|
32
33
|
export type { PriceData } from './stores/price-cache';
|
|
34
|
+
export type { PortfolioData, ChartData } from './stores/portfolio-cache';
|
|
33
35
|
|
|
34
36
|
// Config type export
|
|
35
37
|
export type { CacheManagerConfig } from './core/cache-manager';
|