@pioneer-platform/pioneer-cache 1.0.1 → 1.0.3
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 +14 -0
- package/dist/core/base-cache.d.ts +1 -0
- package/dist/core/base-cache.js +74 -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 +7 -1
- package/dist/stores/balance-cache.js +108 -16
- 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 +4 -4
- package/src/core/base-cache.ts +85 -9
- package/src/core/cache-manager.ts +34 -2
- package/src/index.ts +2 -0
- package/src/stores/balance-cache.ts +117 -16
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pioneer-platform/pioneer-cache",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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": "^8.
|
|
24
|
-
"@pioneer-platform/redis-queue": "^8.
|
|
25
|
-
"@pioneer-platform/default-redis": "^8.
|
|
23
|
+
"@pioneer-platform/loggerdog": "^8.11.0",
|
|
24
|
+
"@pioneer-platform/redis-queue": "^8.11.1",
|
|
25
|
+
"@pioneer-platform/default-redis": "^8.11.7"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^20.0.0",
|
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,38 @@ 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
|
+
// PERFORMANCE FIX: Reduced from 10000ms to 100ms
|
|
189
|
+
// Local Redis should respond in <10ms. 100ms timeout prevents cascading failures
|
|
190
|
+
// when Redis is overloaded while still catching actual connection issues.
|
|
191
|
+
const timeoutMs = 100;
|
|
192
|
+
const cached = await Promise.race([
|
|
193
|
+
this.redis.get(key),
|
|
194
|
+
new Promise<null>((resolve) => setTimeout(() => {
|
|
195
|
+
log.warn(tag, `⏱️ Redis timeout after ${timeoutMs}ms, returning cache miss`);
|
|
196
|
+
resolve(null);
|
|
197
|
+
}, timeoutMs))
|
|
198
|
+
]);
|
|
199
|
+
|
|
178
200
|
if (!cached) {
|
|
201
|
+
log.debug(tag, `Cache miss: ${key}`);
|
|
179
202
|
return null;
|
|
180
203
|
}
|
|
181
204
|
|
|
182
205
|
const parsed: CachedValue<T> = JSON.parse(cached);
|
|
183
206
|
|
|
184
|
-
// Validate structure
|
|
185
|
-
|
|
207
|
+
// Validate structure - Check for undefined/null, NOT falsy values!
|
|
208
|
+
// CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
|
|
209
|
+
if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
|
|
186
210
|
log.warn(tag, `Invalid cache structure for ${key}, removing`);
|
|
187
211
|
await this.redis.del(key);
|
|
188
212
|
return null;
|
|
189
213
|
}
|
|
190
214
|
|
|
215
|
+
log.debug(tag, `Cache hit: ${key}`);
|
|
191
216
|
return parsed;
|
|
192
217
|
|
|
193
218
|
} catch (error) {
|
|
@@ -212,19 +237,33 @@ export abstract class BaseCache<T> {
|
|
|
212
237
|
metadata
|
|
213
238
|
};
|
|
214
239
|
|
|
240
|
+
// DIAGNOSTIC: Log Redis connection details
|
|
241
|
+
const redisConstructor = this.redis.constructor?.name || 'unknown';
|
|
242
|
+
const redisOptions = this.redis.options || {};
|
|
243
|
+
|
|
215
244
|
// FIX #2: Always set TTL (unless explicitly disabled)
|
|
216
245
|
if (this.config.enableTTL) {
|
|
217
246
|
const ttlSeconds = Math.floor(this.config.ttl / 1000);
|
|
247
|
+
|
|
248
|
+
// PERF: Reduced logging for production performance
|
|
249
|
+
log.debug(tag, `📝 Writing to Redis: ${key} [${JSON.stringify(cachedValue).length} bytes, TTL: ${ttlSeconds}s]`);
|
|
250
|
+
|
|
251
|
+
// Write
|
|
218
252
|
await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
|
|
219
|
-
|
|
253
|
+
|
|
254
|
+
// PERF: Verification disabled for production performance
|
|
255
|
+
// Only enable for debugging cache issues
|
|
256
|
+
log.debug(tag, `✅ Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
|
|
220
257
|
} else {
|
|
221
258
|
// Permanent caching (for transactions)
|
|
222
259
|
await this.redis.set(key, JSON.stringify(cachedValue));
|
|
223
|
-
|
|
260
|
+
|
|
261
|
+
// PERF: Verification disabled for production performance
|
|
262
|
+
log.debug(tag, `✅ Updated cache: ${key} [PERMANENT]`);
|
|
224
263
|
}
|
|
225
264
|
|
|
226
265
|
} catch (error) {
|
|
227
|
-
log.error(tag,
|
|
266
|
+
log.error(tag, `❌ Error updating cache for ${key}:`, error);
|
|
228
267
|
throw error;
|
|
229
268
|
}
|
|
230
269
|
}
|
|
@@ -249,8 +288,13 @@ export abstract class BaseCache<T> {
|
|
|
249
288
|
log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
|
|
250
289
|
log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
|
|
251
290
|
|
|
252
|
-
// FIX #4: Synchronous fallback for high-priority
|
|
253
|
-
|
|
291
|
+
// FIX #4: Synchronous fallback for high-priority (only if useSyncFallback is enabled)
|
|
292
|
+
// Default: use sync fallback only for blocking caches (blockOnMiss=true)
|
|
293
|
+
const shouldUseSyncFallback = this.config.useSyncFallback !== undefined
|
|
294
|
+
? this.config.useSyncFallback
|
|
295
|
+
: this.config.blockOnMiss;
|
|
296
|
+
|
|
297
|
+
if (priority === 'high' && shouldUseSyncFallback) {
|
|
254
298
|
log.warn(tag, `Using synchronous fallback for high-priority refresh`);
|
|
255
299
|
setImmediate(async () => {
|
|
256
300
|
try {
|
|
@@ -295,6 +339,7 @@ export abstract class BaseCache<T> {
|
|
|
295
339
|
* Fetch fresh data and update cache
|
|
296
340
|
* FIX #1 & #4: Used for blocking requests and fallback
|
|
297
341
|
* FIX #6: Request deduplication to prevent thundering herd
|
|
342
|
+
* FIX #8: Return stale cache on fetch failures
|
|
298
343
|
*/
|
|
299
344
|
async fetchFresh(params: Record<string, any>): Promise<T> {
|
|
300
345
|
const tag = this.TAG + 'fetchFresh | ';
|
|
@@ -304,6 +349,13 @@ export abstract class BaseCache<T> {
|
|
|
304
349
|
// FIX #6: Check if there's already a pending fetch for this key
|
|
305
350
|
const existingFetch = this.pendingFetches.get(key);
|
|
306
351
|
if (existingFetch) {
|
|
352
|
+
// For non-blocking caches, return default value immediately instead of waiting
|
|
353
|
+
if (!this.config.blockOnMiss) {
|
|
354
|
+
log.debug(tag, `Non-blocking cache: returning default value while fetch in progress: ${key}`);
|
|
355
|
+
return this.config.defaultValue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// For blocking caches, coalesce to prevent thundering herd
|
|
307
359
|
log.debug(tag, `Coalescing request for: ${key} (fetch already in progress)`);
|
|
308
360
|
return existingFetch;
|
|
309
361
|
}
|
|
@@ -326,7 +378,31 @@ export abstract class BaseCache<T> {
|
|
|
326
378
|
|
|
327
379
|
} catch (error) {
|
|
328
380
|
const fetchTime = Date.now() - startTime;
|
|
329
|
-
|
|
381
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
382
|
+
|
|
383
|
+
// FIX #8: Try to return stale cache on fetch failures
|
|
384
|
+
const cachedValue = await this.getCached(key);
|
|
385
|
+
if (cachedValue) {
|
|
386
|
+
log.warn(tag, `Fetch failed after ${fetchTime}ms, returning stale cache: ${key}`);
|
|
387
|
+
return cachedValue.value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Try legacy cache as last resort
|
|
391
|
+
if (this.config.enableLegacyFallback) {
|
|
392
|
+
const legacyValue = await this.getLegacyCached(params);
|
|
393
|
+
if (legacyValue) {
|
|
394
|
+
log.warn(tag, `Fetch failed after ${fetchTime}ms, returning legacy cache: ${key}`);
|
|
395
|
+
return legacyValue;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Log as warning for expected issues, error for unexpected
|
|
400
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid')) {
|
|
401
|
+
log.warn(tag, `Expected fetch failure after ${fetchTime}ms: ${errorMsg}`);
|
|
402
|
+
} else {
|
|
403
|
+
log.error(tag, `Unexpected fetch failure after ${fetchTime}ms:`, error);
|
|
404
|
+
}
|
|
405
|
+
|
|
330
406
|
return this.config.defaultValue;
|
|
331
407
|
} finally {
|
|
332
408
|
// 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';
|