@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.
@@ -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: 5 * 60 * 1000, // 5 minutes
38
- staleThreshold: 2 * 60 * 1000, // 2 minutes
39
- enableTTL: true,
40
- queueName: 'balance-refresh-v2',
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
- // Get all balances in parallel
178
- const promises = items.map(item => this.getBalance(item.caip, item.pubkey, waitForFresh));
179
- const results = await Promise.all(promises);
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
- log.info(tag, `Batch completed: ${results.length} balances in ${responseTime}ms (${(responseTime / results.length).toFixed(1)}ms avg)`);
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: 60 * 60 * 1000, // 1 hour
33
- staleThreshold: 30 * 60 * 1000, // 30 minutes (refresh after 30min)
34
- enableTTL: true,
35
- queueName: 'price-refresh-v2',
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: true, // MUST wait for price on first request (changed from 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: 5000,
47
+ apiTimeout: 2000, // Reduced from 5000ms for faster failures
47
48
  logCacheHits: false,
48
49
  logCacheMisses: true,
49
50
  logRefreshJobs: true
@@ -69,7 +70,7 @@ export class PriceCache extends BaseCache<PriceData> {
69
70
 
70
71
  /**
71
72
  * Fetch price from markets API using CAIP-first approach
72
- * FIX #7: Throws error on zero price to prevent caching invalid data
73
+ * FIX #7: Graceful handling of zero prices to prevent cache disruption
73
74
  */
74
75
  protected async fetchFromSource(params: Record<string, any>): Promise<PriceData> {
75
76
  const tag = this.TAG + 'fetchFromSource | ';
@@ -81,12 +82,30 @@ export class PriceCache extends BaseCache<PriceData> {
81
82
  // This directly queries the markets module with CAIP identifiers
82
83
  const price = await this.markets.getAssetPriceByCaip(caip);
83
84
 
84
- // FIX #7: Throw error instead of returning 0 price
85
- // This prevents caching invalid $0 prices from rate limits or API failures
85
+ // FIX #7: Gracefully handle zero prices without throwing
86
+ // This prevents disrupting batch operations during API rate limits
86
87
  if (isNaN(price) || price <= 0) {
87
- const errorMsg = `Price fetch failed for ${caip}: got $${price} (likely API timeout or rate limit)`;
88
- log.warn(tag, errorMsg);
89
- throw new Error(errorMsg);
88
+ log.warn(tag, `Price fetch returned $${price} for ${caip} (likely API timeout or rate limit) - returning stale cache if available`);
89
+
90
+ // Try to get stale cached value instead of failing
91
+ const key = this.buildKey(params);
92
+ const cachedValue = await this.getCached(key);
93
+
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
+ }
98
+
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}`);
90
109
  }
91
110
 
92
111
  log.debug(tag, `Fetched price for ${caip}: $${price}`);
@@ -98,9 +117,13 @@ export class PriceCache extends BaseCache<PriceData> {
98
117
  };
99
118
 
100
119
  } catch (error) {
101
- // Re-throw to prevent caching zero prices
102
- // BaseCache.fetchFresh() will catch this and return defaultValue
103
- // but won't cache the error
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
+ }
104
127
  throw error;
105
128
  }
106
129
  }
@@ -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