@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
|
@@ -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';
|
|
@@ -34,14 +34,14 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
34
34
|
const defaultConfig: CacheConfig = {
|
|
35
35
|
name: 'balance',
|
|
36
36
|
keyPrefix: 'balance_v2:',
|
|
37
|
-
ttl:
|
|
38
|
-
staleThreshold:
|
|
39
|
-
enableTTL:
|
|
40
|
-
queueName: '
|
|
37
|
+
ttl: 0, // Ignored when enableTTL: false
|
|
38
|
+
staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
|
|
39
|
+
enableTTL: false, // NEVER EXPIRE - data persists forever
|
|
40
|
+
queueName: 'cache-refresh',
|
|
41
41
|
enableQueue: true,
|
|
42
42
|
maxRetries: 3,
|
|
43
43
|
retryDelay: 10000,
|
|
44
|
-
blockOnMiss: true, // Wait for fresh data on first request
|
|
44
|
+
blockOnMiss: true, // Wait for fresh data on first request - users need real balances!
|
|
45
45
|
enableLegacyFallback: true,
|
|
46
46
|
defaultValue: {
|
|
47
47
|
caip: '',
|
|
@@ -166,20 +166,79 @@ export class BalanceCache extends BaseCache<BalanceData> {
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Get balances for multiple assets (batch operation)
|
|
169
|
+
* OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
|
|
169
170
|
*/
|
|
170
171
|
async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
|
|
171
172
|
const tag = this.TAG + 'getBatchBalances | ';
|
|
172
173
|
const startTime = Date.now();
|
|
173
174
|
|
|
174
175
|
try {
|
|
175
|
-
log.info(tag, `Batch request for ${items.length} balances`);
|
|
176
|
+
log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
|
|
177
|
+
|
|
178
|
+
// Build all Redis keys
|
|
179
|
+
const keys = items.map(item => this.buildKey({ caip: item.caip, pubkey: item.pubkey }));
|
|
180
|
+
|
|
181
|
+
// PERF: Use MGET to fetch all keys in ONE Redis round-trip
|
|
182
|
+
const cachedValues = await this.redis.mget(...keys);
|
|
183
|
+
|
|
184
|
+
// Process results
|
|
185
|
+
const results: BalanceData[] = [];
|
|
186
|
+
const missedItems: Array<{ caip: string; pubkey: string; index: number }> = [];
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < items.length; i++) {
|
|
189
|
+
const item = items[i];
|
|
190
|
+
const cached = cachedValues[i];
|
|
191
|
+
|
|
192
|
+
if (cached) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(cached);
|
|
195
|
+
if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
|
|
196
|
+
results[i] = parsed.value;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
176
203
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
204
|
+
// Cache miss - record for fetching
|
|
205
|
+
missedItems.push({ ...item, index: i });
|
|
206
|
+
results[i] = this.config.defaultValue; // Placeholder
|
|
207
|
+
}
|
|
180
208
|
|
|
181
209
|
const responseTime = Date.now() - startTime;
|
|
182
|
-
|
|
210
|
+
const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
|
|
211
|
+
log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
|
|
212
|
+
|
|
213
|
+
// If we have cache misses and blocking is enabled, fetch them
|
|
214
|
+
if (missedItems.length > 0) {
|
|
215
|
+
const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
|
|
216
|
+
|
|
217
|
+
if (shouldBlock) {
|
|
218
|
+
log.info(tag, `Fetching ${missedItems.length} cache misses...`);
|
|
219
|
+
const fetchStart = Date.now();
|
|
220
|
+
|
|
221
|
+
// Fetch all misses in parallel
|
|
222
|
+
const fetchPromises = missedItems.map(async (item) => {
|
|
223
|
+
try {
|
|
224
|
+
// Use fetchFresh to ensure Redis is updated and requests are deduplicated
|
|
225
|
+
const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
|
|
226
|
+
results[item.index] = freshData;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
|
|
229
|
+
results[item.index] = { caip: item.caip, pubkey: item.pubkey, balance: '0' };
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await Promise.all(fetchPromises);
|
|
234
|
+
log.info(tag, `Fetched ${missedItems.length} misses in ${Date.now() - fetchStart}ms`);
|
|
235
|
+
} else {
|
|
236
|
+
// Non-blocking: trigger background refresh for misses
|
|
237
|
+
missedItems.forEach(item => {
|
|
238
|
+
this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'high');
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
183
242
|
|
|
184
243
|
return results;
|
|
185
244
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*
|
|
2
|
+
PortfolioCache - Portfolio/Charts cache implementation
|
|
3
|
+
|
|
4
|
+
Extends BaseCache with portfolio-specific logic.
|
|
5
|
+
Designed for NON-BLOCKING, instant returns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BaseCache } from '../core/base-cache';
|
|
9
|
+
import type { CacheConfig } from '../types';
|
|
10
|
+
|
|
11
|
+
const log = require('@pioneer-platform/loggerdog')();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Portfolio chart data structure
|
|
15
|
+
* Represents a single asset balance with pricing for charts
|
|
16
|
+
*/
|
|
17
|
+
export interface ChartData {
|
|
18
|
+
caip: string;
|
|
19
|
+
pubkey: string;
|
|
20
|
+
networkId: string;
|
|
21
|
+
symbol: string;
|
|
22
|
+
name: string;
|
|
23
|
+
balance: string;
|
|
24
|
+
priceUsd: number;
|
|
25
|
+
valueUsd: number;
|
|
26
|
+
icon?: string;
|
|
27
|
+
type?: string; // 'native', 'token', etc.
|
|
28
|
+
decimal?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Full portfolio data for a pubkey set
|
|
33
|
+
*/
|
|
34
|
+
export interface PortfolioData {
|
|
35
|
+
pubkeys: Array<{ pubkey: string; caip: string }>;
|
|
36
|
+
charts: ChartData[];
|
|
37
|
+
totalValueUsd: number;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* PortfolioCache - Caches portfolio/chart data
|
|
43
|
+
*
|
|
44
|
+
* CRITICAL: This cache is NON-BLOCKING by design
|
|
45
|
+
* - Returns empty arrays immediately on cache miss
|
|
46
|
+
* - Never blocks waiting for blockchain APIs
|
|
47
|
+
* - Background jobs populate cache for next request
|
|
48
|
+
*/
|
|
49
|
+
export class PortfolioCache extends BaseCache<PortfolioData> {
|
|
50
|
+
private balanceModule: any;
|
|
51
|
+
private marketsModule: any;
|
|
52
|
+
|
|
53
|
+
constructor(redis: any, balanceModule: any, marketsModule: any, config?: Partial<CacheConfig>) {
|
|
54
|
+
const defaultConfig: CacheConfig = {
|
|
55
|
+
name: 'portfolio',
|
|
56
|
+
keyPrefix: 'portfolio_v2:',
|
|
57
|
+
ttl: 0, // Ignored when enableTTL: false
|
|
58
|
+
staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
|
|
59
|
+
enableTTL: false, // NEVER EXPIRE - data persists forever, show stale data instantly
|
|
60
|
+
queueName: 'cache-refresh',
|
|
61
|
+
enableQueue: true,
|
|
62
|
+
maxRetries: 3,
|
|
63
|
+
retryDelay: 5000,
|
|
64
|
+
blockOnMiss: false, // CRITICAL: NEVER WAIT! Return empty arrays instantly
|
|
65
|
+
enableLegacyFallback: false, // No legacy portfolio cache format
|
|
66
|
+
defaultValue: {
|
|
67
|
+
pubkeys: [],
|
|
68
|
+
charts: [],
|
|
69
|
+
totalValueUsd: 0,
|
|
70
|
+
timestamp: Date.now()
|
|
71
|
+
},
|
|
72
|
+
useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly
|
|
73
|
+
maxConcurrentJobs: 3, // Limit concurrent portfolio refreshes
|
|
74
|
+
apiTimeout: 30000, // 30s timeout for full portfolio fetch
|
|
75
|
+
logCacheHits: true,
|
|
76
|
+
logCacheMisses: true,
|
|
77
|
+
logRefreshJobs: true
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
super(redis, { ...defaultConfig, ...config });
|
|
81
|
+
this.balanceModule = balanceModule;
|
|
82
|
+
this.marketsModule = marketsModule;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build Redis key for portfolio data
|
|
87
|
+
*
|
|
88
|
+
* Key strategy: Hash all pubkeys+caips to create a stable identifier
|
|
89
|
+
* Format: portfolio_v2:hash(pubkeys)
|
|
90
|
+
*
|
|
91
|
+
* This allows caching the same portfolio regardless of pubkey order
|
|
92
|
+
*/
|
|
93
|
+
protected buildKey(params: Record<string, any>): string {
|
|
94
|
+
const { pubkeys } = params;
|
|
95
|
+
if (!pubkeys || !Array.isArray(pubkeys) || pubkeys.length === 0) {
|
|
96
|
+
throw new Error('PortfolioCache.buildKey: pubkeys array required');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Sort pubkeys to create stable hash regardless of order
|
|
100
|
+
const sorted = [...pubkeys].sort((a, b) => {
|
|
101
|
+
const aKey = `${a.caip}:${a.pubkey}`;
|
|
102
|
+
const bKey = `${b.caip}:${b.pubkey}`;
|
|
103
|
+
return aKey.localeCompare(bKey);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Create a simple hash from sorted pubkeys
|
|
107
|
+
const keyString = sorted.map(p => `${p.caip}:${p.pubkey}`).join('|');
|
|
108
|
+
const hash = this.simpleHash(keyString);
|
|
109
|
+
|
|
110
|
+
return `${this.config.keyPrefix}${hash}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Simple hash function for cache keys
|
|
115
|
+
* Not cryptographic - just needs to be stable and collision-resistant
|
|
116
|
+
*/
|
|
117
|
+
private simpleHash(str: string): string {
|
|
118
|
+
let hash = 0;
|
|
119
|
+
for (let i = 0; i < str.length; i++) {
|
|
120
|
+
const char = str.charCodeAt(i);
|
|
121
|
+
hash = ((hash << 5) - hash) + char;
|
|
122
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
123
|
+
}
|
|
124
|
+
return Math.abs(hash).toString(36);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetch portfolio from blockchain APIs
|
|
129
|
+
*
|
|
130
|
+
* This is the SLOW operation that happens in the background
|
|
131
|
+
* It fetches balances for all pubkeys and enriches with pricing
|
|
132
|
+
*/
|
|
133
|
+
protected async fetchFromSource(params: Record<string, any>): Promise<PortfolioData> {
|
|
134
|
+
const tag = this.TAG + 'fetchFromSource | ';
|
|
135
|
+
const startTime = Date.now();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const { pubkeys } = params;
|
|
139
|
+
log.info(tag, `Fetching portfolio for ${pubkeys.length} pubkeys`);
|
|
140
|
+
|
|
141
|
+
const charts: ChartData[] = [];
|
|
142
|
+
|
|
143
|
+
// Fetch balances for all pubkeys in parallel
|
|
144
|
+
const balancePromises = pubkeys.map(async (item: { pubkey: string; caip: string }) => {
|
|
145
|
+
try {
|
|
146
|
+
// Extract networkId from CAIP
|
|
147
|
+
const networkId = item.caip.split('/')[0];
|
|
148
|
+
|
|
149
|
+
// Fetch balance
|
|
150
|
+
const asset = { caip: item.caip };
|
|
151
|
+
const owner = { pubkey: item.pubkey };
|
|
152
|
+
const balanceInfo = await this.balanceModule.getBalance(asset, owner);
|
|
153
|
+
|
|
154
|
+
if (!balanceInfo || !balanceInfo.balance) {
|
|
155
|
+
log.debug(tag, `No balance for ${item.caip}/${item.pubkey.substring(0, 10)}...`);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Skip zero balances
|
|
160
|
+
const balanceNum = parseFloat(balanceInfo.balance);
|
|
161
|
+
if (isNaN(balanceNum) || balanceNum === 0) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Get asset metadata
|
|
166
|
+
const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
|
|
167
|
+
const assetInfo = assetData[item.caip.toUpperCase()] || assetData[item.caip.toLowerCase()] || {};
|
|
168
|
+
|
|
169
|
+
// Get price
|
|
170
|
+
let priceUsd = 0;
|
|
171
|
+
try {
|
|
172
|
+
priceUsd = await this.marketsModule.getAssetPriceByCaip(item.caip);
|
|
173
|
+
if (isNaN(priceUsd) || priceUsd < 0) {
|
|
174
|
+
priceUsd = 0;
|
|
175
|
+
}
|
|
176
|
+
} catch (priceError) {
|
|
177
|
+
log.warn(tag, `Error fetching price for ${item.caip}:`, priceError);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const valueUsd = balanceNum * priceUsd;
|
|
181
|
+
|
|
182
|
+
const chartData: ChartData = {
|
|
183
|
+
caip: item.caip,
|
|
184
|
+
pubkey: item.pubkey,
|
|
185
|
+
networkId,
|
|
186
|
+
symbol: assetInfo.symbol || 'UNKNOWN',
|
|
187
|
+
name: assetInfo.name || 'Unknown Asset',
|
|
188
|
+
balance: balanceInfo.balance,
|
|
189
|
+
priceUsd,
|
|
190
|
+
valueUsd,
|
|
191
|
+
icon: assetInfo.icon || '',
|
|
192
|
+
type: assetInfo.type || 'native',
|
|
193
|
+
decimal: assetInfo.decimal
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return chartData;
|
|
197
|
+
|
|
198
|
+
} catch (error) {
|
|
199
|
+
log.error(tag, `Error fetching balance for ${item.caip}/${item.pubkey}:`, error);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const results = await Promise.all(balancePromises);
|
|
205
|
+
|
|
206
|
+
// Filter out nulls and calculate total
|
|
207
|
+
const validCharts = results.filter((c): c is ChartData => c !== null);
|
|
208
|
+
const totalValueUsd = validCharts.reduce((sum, c) => sum + c.valueUsd, 0);
|
|
209
|
+
|
|
210
|
+
const fetchTime = Date.now() - startTime;
|
|
211
|
+
log.info(tag, `✅ Fetched portfolio: ${validCharts.length} assets, $${totalValueUsd.toFixed(2)} in ${fetchTime}ms`);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
pubkeys,
|
|
215
|
+
charts: validCharts,
|
|
216
|
+
totalValueUsd,
|
|
217
|
+
timestamp: Date.now()
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
} catch (error) {
|
|
221
|
+
log.error(tag, 'Error fetching portfolio:', error);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* No legacy cache format for portfolios
|
|
228
|
+
*/
|
|
229
|
+
protected async getLegacyCached(params: Record<string, any>): Promise<PortfolioData | null> {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get portfolio for a set of pubkeys
|
|
235
|
+
* Convenience method that wraps base get()
|
|
236
|
+
*
|
|
237
|
+
* RETURNS INSTANTLY - either cached data or empty arrays
|
|
238
|
+
*/
|
|
239
|
+
async getPortfolio(pubkeys: Array<{ pubkey: string; caip: string }>, waitForFresh?: boolean): Promise<PortfolioData> {
|
|
240
|
+
const result = await this.get({ pubkeys }, waitForFresh);
|
|
241
|
+
return result.value || this.config.defaultValue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
@@ -29,21 +29,22 @@ export class PriceCache extends BaseCache<PriceData> {
|
|
|
29
29
|
const defaultConfig: CacheConfig = {
|
|
30
30
|
name: 'price',
|
|
31
31
|
keyPrefix: 'price_v2:',
|
|
32
|
-
ttl:
|
|
33
|
-
staleThreshold: 30 * 60 * 1000, // 30 minutes
|
|
34
|
-
enableTTL:
|
|
35
|
-
queueName: '
|
|
32
|
+
ttl: 0, // Ignored when enableTTL: false
|
|
33
|
+
staleThreshold: 30 * 60 * 1000, // 30 minutes - triggers background refresh
|
|
34
|
+
enableTTL: false, // NEVER EXPIRE - data persists forever, show stale prices instantly
|
|
35
|
+
queueName: 'cache-refresh',
|
|
36
36
|
enableQueue: true,
|
|
37
37
|
maxRetries: 3,
|
|
38
38
|
retryDelay: 5000,
|
|
39
|
-
blockOnMiss: false, //
|
|
39
|
+
blockOnMiss: false, // CRITICAL: NEVER WAIT! Return $0 instantly on cache miss, refresh async in background
|
|
40
40
|
enableLegacyFallback: true,
|
|
41
41
|
defaultValue: {
|
|
42
42
|
caip: '',
|
|
43
43
|
price: 0
|
|
44
44
|
},
|
|
45
|
+
useSyncFallback: false, // CRITICAL: NEVER use synchronous fallback - always return instantly with $0
|
|
45
46
|
maxConcurrentJobs: 5,
|
|
46
|
-
apiTimeout:
|
|
47
|
+
apiTimeout: 2000, // Reduced from 5000ms for faster failures
|
|
47
48
|
logCacheHits: false,
|
|
48
49
|
logCacheMisses: true,
|
|
49
50
|
logRefreshJobs: true
|
|
@@ -68,7 +69,8 @@ export class PriceCache extends BaseCache<PriceData> {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* Fetch price from markets API
|
|
72
|
+
* Fetch price from markets API using CAIP-first approach
|
|
73
|
+
* FIX #7: Graceful handling of zero prices to prevent cache disruption
|
|
72
74
|
*/
|
|
73
75
|
protected async fetchFromSource(params: Record<string, any>): Promise<PriceData> {
|
|
74
76
|
const tag = this.TAG + 'fetchFromSource | ';
|
|
@@ -76,48 +78,52 @@ export class PriceCache extends BaseCache<PriceData> {
|
|
|
76
78
|
try {
|
|
77
79
|
const { caip } = params;
|
|
78
80
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
81
|
+
// Use CAIP-first API (no symbol conversion needed!)
|
|
82
|
+
// This directly queries the markets module with CAIP identifiers
|
|
83
|
+
const price = await this.markets.getAssetPriceByCaip(caip);
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
price: 0
|
|
88
|
-
};
|
|
89
|
-
}
|
|
85
|
+
// FIX #7: Gracefully handle zero prices without throwing
|
|
86
|
+
// This prevents disrupting batch operations during API rate limits
|
|
87
|
+
if (isNaN(price) || price <= 0) {
|
|
88
|
+
log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// Try to get stale cached value instead of failing
|
|
91
|
+
const key = this.buildKey(params);
|
|
92
|
+
const cachedValue = await this.getCached(key);
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} else if (typeof priceResult === 'number') {
|
|
100
|
-
price = priceResult;
|
|
101
|
-
}
|
|
94
|
+
if (cachedValue && cachedValue.value.price > 0) {
|
|
95
|
+
log.info(tag, `Returning stale cached price for ${caip}: $${cachedValue.value.price}`);
|
|
96
|
+
return cachedValue.value;
|
|
97
|
+
}
|
|
102
98
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
caip
|
|
107
|
-
|
|
108
|
-
}
|
|
99
|
+
// Try legacy cache as fallback
|
|
100
|
+
const legacyValue = await this.getLegacyCached(params);
|
|
101
|
+
if (legacyValue && legacyValue.price > 0) {
|
|
102
|
+
log.info(tag, `Returning legacy cached price for ${caip}: $${legacyValue.price}`);
|
|
103
|
+
return legacyValue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Last resort: return zero price but don't cache it
|
|
107
|
+
log.warn(tag, `No cached price available for ${caip}, returning zero`);
|
|
108
|
+
throw new Error(`No valid price available for ${caip}`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
log.debug(tag, `Fetched price for ${caip}
|
|
111
|
+
log.debug(tag, `Fetched price for ${caip}: $${price}`);
|
|
112
112
|
|
|
113
113
|
return {
|
|
114
114
|
caip,
|
|
115
115
|
price,
|
|
116
|
-
source: 'markets'
|
|
116
|
+
source: 'markets-caip'
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
} catch (error) {
|
|
120
|
-
|
|
120
|
+
// Log as warning instead of error for expected API issues
|
|
121
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
122
|
+
if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid price')) {
|
|
123
|
+
log.warn(tag, `Expected API issue: ${errorMsg}`);
|
|
124
|
+
} else {
|
|
125
|
+
log.error(tag, `Unexpected error fetching price:`, error);
|
|
126
|
+
}
|
|
121
127
|
throw error;
|
|
122
128
|
}
|
|
123
129
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface CacheConfig {
|
|
|
25
25
|
blockOnMiss: boolean; // Wait for fresh data on cache miss
|
|
26
26
|
enableLegacyFallback: boolean; // Try legacy cache keys on miss
|
|
27
27
|
defaultValue: any; // Default value to return on error
|
|
28
|
+
useSyncFallback?: boolean; // Use synchronous fallback when queue fails (default: true for blockOnMiss, false otherwise)
|
|
28
29
|
|
|
29
30
|
// Performance
|
|
30
31
|
maxConcurrentJobs: number; // Max jobs processed concurrently
|