@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.
@@ -1 +1,2 @@
1
- $ tsc
1
+
2
+ $ tsc
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @pioneer-platform/pioneer-cache
2
2
 
3
+ ## 1.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - cache work
8
+ - Updated dependencies
9
+ - @pioneer-platform/redis-queue@8.11.1
10
+
11
+ ## 1.0.2
12
+
13
+ ### Patch Changes
14
+
15
+ - feed4f1: Increase Redis cache timeout from 100ms to 2000ms to prevent false cache misses and reduce debug logging
16
+
3
17
  ## 1.0.1
4
18
 
5
19
  ### Patch Changes
@@ -38,6 +38,7 @@ export declare abstract class BaseCache<T> {
38
38
  * Fetch fresh data and update cache
39
39
  * FIX #1 & #4: Used for blocking requests and fallback
40
40
  * FIX #6: Request deduplication to prevent thundering herd
41
+ * FIX #8: Return stale cache on fetch failures
41
42
  */
42
43
  fetchFresh(params: Record<string, any>): Promise<T>;
43
44
  /**
@@ -57,9 +57,13 @@ class BaseCache {
57
57
  const tag = this.TAG + 'get | ';
58
58
  const startTime = Date.now();
59
59
  try {
60
+ const t1 = Date.now();
60
61
  const key = this.buildKey(params);
62
+ log.info(tag, `⏱️ buildKey took ${Date.now() - t1}ms`);
61
63
  // Step 1: Try new cache format
64
+ const t2 = Date.now();
62
65
  const cachedValue = await this.getCached(key);
66
+ log.info(tag, `⏱️ getCached took ${Date.now() - t2}ms`);
63
67
  if (cachedValue) {
64
68
  const age = Date.now() - cachedValue.timestamp;
65
69
  const responseTime = Date.now() - startTime;
@@ -80,7 +84,9 @@ class BaseCache {
80
84
  }
81
85
  // Step 2: Try legacy cache fallback
82
86
  if (this.config.enableLegacyFallback) {
87
+ const t3 = Date.now();
83
88
  const legacyValue = await this.getLegacyCached(params);
89
+ log.info(tag, `⏱️ getLegacyCached took ${Date.now() - t3}ms`);
84
90
  if (legacyValue) {
85
91
  const responseTime = Date.now() - startTime;
86
92
  log.info(tag, `Legacy cache hit: ${key} (${responseTime}ms)`);
@@ -114,7 +120,10 @@ class BaseCache {
114
120
  };
115
121
  }
116
122
  // Non-blocking: trigger async refresh and return default
123
+ const t4 = Date.now();
117
124
  this.triggerAsyncRefresh(params, 'high');
125
+ log.info(tag, `⏱️ triggerAsyncRefresh took ${Date.now() - t4}ms`);
126
+ log.info(tag, `⏱️ Returning default value after ${Date.now() - startTime}ms TOTAL`);
118
127
  return {
119
128
  success: true,
120
129
  value: this.config.defaultValue,
@@ -139,18 +148,33 @@ class BaseCache {
139
148
  */
140
149
  async getCached(key) {
141
150
  const tag = this.TAG + 'getCached | ';
151
+ const t0 = Date.now();
142
152
  try {
143
- const cached = await this.redis.get(key);
153
+ // Redis timeout for cache reads
154
+ // PERFORMANCE FIX: Reduced from 10000ms to 100ms
155
+ // Local Redis should respond in <10ms. 100ms timeout prevents cascading failures
156
+ // when Redis is overloaded while still catching actual connection issues.
157
+ const timeoutMs = 100;
158
+ const cached = await Promise.race([
159
+ this.redis.get(key),
160
+ new Promise((resolve) => setTimeout(() => {
161
+ log.warn(tag, `⏱️ Redis timeout after ${timeoutMs}ms, returning cache miss`);
162
+ resolve(null);
163
+ }, timeoutMs))
164
+ ]);
144
165
  if (!cached) {
166
+ log.debug(tag, `Cache miss: ${key}`);
145
167
  return null;
146
168
  }
147
169
  const parsed = JSON.parse(cached);
148
- // Validate structure
149
- if (!parsed.value || typeof parsed.timestamp !== 'number') {
170
+ // Validate structure - Check for undefined/null, NOT falsy values!
171
+ // CRITICAL: Balance "0", empty arrays [], and empty objects {} are VALID!
172
+ if (parsed.value === undefined || parsed.value === null || typeof parsed.timestamp !== 'number') {
150
173
  log.warn(tag, `Invalid cache structure for ${key}, removing`);
151
174
  await this.redis.del(key);
152
175
  return null;
153
176
  }
177
+ log.debug(tag, `Cache hit: ${key}`);
154
178
  return parsed;
155
179
  }
156
180
  catch (error) {
@@ -172,20 +196,29 @@ class BaseCache {
172
196
  lastUpdated: new Date().toISOString(),
173
197
  metadata
174
198
  };
199
+ // DIAGNOSTIC: Log Redis connection details
200
+ const redisConstructor = this.redis.constructor?.name || 'unknown';
201
+ const redisOptions = this.redis.options || {};
175
202
  // FIX #2: Always set TTL (unless explicitly disabled)
176
203
  if (this.config.enableTTL) {
177
204
  const ttlSeconds = Math.floor(this.config.ttl / 1000);
205
+ // PERF: Reduced logging for production performance
206
+ log.debug(tag, `📝 Writing to Redis: ${key} [${JSON.stringify(cachedValue).length} bytes, TTL: ${ttlSeconds}s]`);
207
+ // Write
178
208
  await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
179
- log.info(tag, `Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
209
+ // PERF: Verification disabled for production performance
210
+ // Only enable for debugging cache issues
211
+ log.debug(tag, `✅ Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
180
212
  }
181
213
  else {
182
214
  // Permanent caching (for transactions)
183
215
  await this.redis.set(key, JSON.stringify(cachedValue));
184
- log.info(tag, `Updated cache: ${key} [PERMANENT]`);
216
+ // PERF: Verification disabled for production performance
217
+ log.debug(tag, `✅ Updated cache: ${key} [PERMANENT]`);
185
218
  }
186
219
  }
187
220
  catch (error) {
188
- log.error(tag, `Error updating cache for ${key}:`, error);
221
+ log.error(tag, `❌ Error updating cache for ${key}:`, error);
189
222
  throw error;
190
223
  }
191
224
  }
@@ -206,8 +239,12 @@ class BaseCache {
206
239
  const key = this.buildKey(params);
207
240
  log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
208
241
  log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
209
- // FIX #4: Synchronous fallback for high-priority
210
- if (priority === 'high') {
242
+ // FIX #4: Synchronous fallback for high-priority (only if useSyncFallback is enabled)
243
+ // Default: use sync fallback only for blocking caches (blockOnMiss=true)
244
+ const shouldUseSyncFallback = this.config.useSyncFallback !== undefined
245
+ ? this.config.useSyncFallback
246
+ : this.config.blockOnMiss;
247
+ if (priority === 'high' && shouldUseSyncFallback) {
211
248
  log.warn(tag, `Using synchronous fallback for high-priority refresh`);
212
249
  setImmediate(async () => {
213
250
  try {
@@ -249,6 +286,7 @@ class BaseCache {
249
286
  * Fetch fresh data and update cache
250
287
  * FIX #1 & #4: Used for blocking requests and fallback
251
288
  * FIX #6: Request deduplication to prevent thundering herd
289
+ * FIX #8: Return stale cache on fetch failures
252
290
  */
253
291
  async fetchFresh(params) {
254
292
  const tag = this.TAG + 'fetchFresh | ';
@@ -257,6 +295,12 @@ class BaseCache {
257
295
  // FIX #6: Check if there's already a pending fetch for this key
258
296
  const existingFetch = this.pendingFetches.get(key);
259
297
  if (existingFetch) {
298
+ // For non-blocking caches, return default value immediately instead of waiting
299
+ if (!this.config.blockOnMiss) {
300
+ log.debug(tag, `Non-blocking cache: returning default value while fetch in progress: ${key}`);
301
+ return this.config.defaultValue;
302
+ }
303
+ // For blocking caches, coalesce to prevent thundering herd
260
304
  log.debug(tag, `Coalescing request for: ${key} (fetch already in progress)`);
261
305
  return existingFetch;
262
306
  }
@@ -274,7 +318,28 @@ class BaseCache {
274
318
  }
275
319
  catch (error) {
276
320
  const fetchTime = Date.now() - startTime;
277
- log.error(tag, `Failed to fetch fresh data after ${fetchTime}ms:`, error);
321
+ const errorMsg = error instanceof Error ? error.message : String(error);
322
+ // FIX #8: Try to return stale cache on fetch failures
323
+ const cachedValue = await this.getCached(key);
324
+ if (cachedValue) {
325
+ log.warn(tag, `Fetch failed after ${fetchTime}ms, returning stale cache: ${key}`);
326
+ return cachedValue.value;
327
+ }
328
+ // Try legacy cache as last resort
329
+ if (this.config.enableLegacyFallback) {
330
+ const legacyValue = await this.getLegacyCached(params);
331
+ if (legacyValue) {
332
+ log.warn(tag, `Fetch failed after ${fetchTime}ms, returning legacy cache: ${key}`);
333
+ return legacyValue;
334
+ }
335
+ }
336
+ // Log as warning for expected issues, error for unexpected
337
+ if (errorMsg.includes('rate limit') || errorMsg.includes('timeout') || errorMsg.includes('No valid')) {
338
+ log.warn(tag, `Expected fetch failure after ${fetchTime}ms: ${errorMsg}`);
339
+ }
340
+ else {
341
+ log.error(tag, `Unexpected fetch failure after ${fetchTime}ms:`, error);
342
+ }
278
343
  return this.config.defaultValue;
279
344
  }
280
345
  finally {
@@ -1,5 +1,6 @@
1
1
  import { BalanceCache } from '../stores/balance-cache';
2
2
  import { PriceCache } from '../stores/price-cache';
3
+ import { PortfolioCache } from '../stores/portfolio-cache';
3
4
  import { TransactionCache } from '../stores/transaction-cache';
4
5
  import type { HealthCheckResult } from '../types';
5
6
  /**
@@ -11,6 +12,7 @@ export interface CacheManagerConfig {
11
12
  markets?: any;
12
13
  enableBalanceCache?: boolean;
13
14
  enablePriceCache?: boolean;
15
+ enablePortfolioCache?: boolean;
14
16
  enableTransactionCache?: boolean;
15
17
  startWorkers?: boolean;
16
18
  }
@@ -21,6 +23,7 @@ export declare class CacheManager {
21
23
  private redis;
22
24
  private balanceCache?;
23
25
  private priceCache?;
26
+ private portfolioCache?;
24
27
  private transactionCache?;
25
28
  private workers;
26
29
  constructor(config: CacheManagerConfig);
@@ -45,18 +48,20 @@ export declare class CacheManager {
45
48
  getCaches(): {
46
49
  balance: BalanceCache | undefined;
47
50
  price: PriceCache | undefined;
51
+ portfolio: PortfolioCache | undefined;
48
52
  transaction: TransactionCache | undefined;
49
53
  };
50
54
  /**
51
55
  * Get specific cache by name
52
56
  */
53
- getCache(name: 'balance' | 'price' | 'transaction'): BalanceCache | PriceCache | TransactionCache | undefined;
57
+ getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | undefined;
54
58
  /**
55
59
  * Clear all caches (use with caution!)
56
60
  */
57
61
  clearAll(): Promise<{
58
62
  balance?: number;
59
63
  price?: number;
64
+ portfolio?: number;
60
65
  transaction?: number;
61
66
  }>;
62
67
  }
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.CacheManager = void 0;
10
10
  const balance_cache_1 = require("../stores/balance-cache");
11
11
  const price_cache_1 = require("../stores/price-cache");
12
+ const portfolio_cache_1 = require("../stores/portfolio-cache");
12
13
  const transaction_cache_1 = require("../stores/transaction-cache");
13
14
  const refresh_worker_1 = require("../workers/refresh-worker");
14
15
  const log = require('@pioneer-platform/loggerdog')();
@@ -30,6 +31,11 @@ class CacheManager {
30
31
  this.priceCache = new price_cache_1.PriceCache(this.redis, config.markets);
31
32
  log.info(TAG, '✅ Price cache initialized');
32
33
  }
34
+ // Initialize Portfolio Cache
35
+ if (config.enablePortfolioCache !== false && config.balanceModule && config.markets) {
36
+ this.portfolioCache = new portfolio_cache_1.PortfolioCache(this.redis, config.balanceModule, config.markets);
37
+ log.info(TAG, '✅ Portfolio cache initialized');
38
+ }
33
39
  // Initialize Transaction Cache
34
40
  if (config.enableTransactionCache !== false) {
35
41
  this.transactionCache = new transaction_cache_1.TransactionCache(this.redis);
@@ -58,6 +64,9 @@ class CacheManager {
58
64
  if (this.priceCache) {
59
65
  cacheRegistry.set('price', this.priceCache);
60
66
  }
67
+ if (this.portfolioCache) {
68
+ cacheRegistry.set('portfolio', this.portfolioCache);
69
+ }
61
70
  // Start unified worker if we have any caches with queues
62
71
  if (cacheRegistry.size > 0) {
63
72
  const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redis, cacheRegistry, 'cache-refresh', // Unified queue name
@@ -125,6 +134,17 @@ class CacheManager {
125
134
  warnings.push(...priceHealth.warnings.map(w => `Price: ${w}`));
126
135
  }
127
136
  }
137
+ // Check portfolio cache
138
+ if (this.portfolioCache) {
139
+ const portfolioHealth = await this.portfolioCache.getHealth(forceRefresh);
140
+ checks.portfolio = portfolioHealth;
141
+ if (portfolioHealth.status === 'unhealthy') {
142
+ issues.push(...portfolioHealth.issues.map(i => `Portfolio: ${i}`));
143
+ }
144
+ else if (portfolioHealth.status === 'degraded') {
145
+ warnings.push(...portfolioHealth.warnings.map(w => `Portfolio: ${w}`));
146
+ }
147
+ }
128
148
  // Check transaction cache (simple stats check)
129
149
  if (this.transactionCache) {
130
150
  const txStats = await this.transactionCache.getStats();
@@ -192,6 +212,7 @@ class CacheManager {
192
212
  return {
193
213
  balance: this.balanceCache,
194
214
  price: this.priceCache,
215
+ portfolio: this.portfolioCache,
195
216
  transaction: this.transactionCache
196
217
  };
197
218
  }
@@ -204,6 +225,8 @@ class CacheManager {
204
225
  return this.balanceCache;
205
226
  case 'price':
206
227
  return this.priceCache;
228
+ case 'portfolio':
229
+ return this.portfolioCache;
207
230
  case 'transaction':
208
231
  return this.transactionCache;
209
232
  default:
@@ -223,6 +246,9 @@ class CacheManager {
223
246
  if (this.priceCache) {
224
247
  result.price = await this.priceCache.clearAll();
225
248
  }
249
+ if (this.portfolioCache) {
250
+ result.portfolio = await this.portfolioCache.clearAll();
251
+ }
226
252
  if (this.transactionCache) {
227
253
  result.transaction = await this.transactionCache.clearAll();
228
254
  }
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@ export { BaseCache } from './core/base-cache';
2
2
  export { CacheManager } from './core/cache-manager';
3
3
  export { BalanceCache } from './stores/balance-cache';
4
4
  export { PriceCache } from './stores/price-cache';
5
+ export { PortfolioCache } from './stores/portfolio-cache';
5
6
  export { TransactionCache } from './stores/transaction-cache';
6
7
  export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
7
8
  export type { CacheConfig, CachedValue, CacheResult, RefreshJob, HealthCheckResult, CacheStats } from './types';
8
9
  export type { BalanceData } from './stores/balance-cache';
9
10
  export type { PriceData } from './stores/price-cache';
11
+ export type { PortfolioData, ChartData } from './stores/portfolio-cache';
10
12
  export type { CacheManagerConfig } from './core/cache-manager';
11
13
  export type { WorkerConfig } from './workers/refresh-worker';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  Provides stale-while-revalidate caching with TTL, background refresh, and health monitoring.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.startUnifiedWorker = exports.RefreshWorker = exports.TransactionCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
9
+ exports.startUnifiedWorker = exports.RefreshWorker = exports.TransactionCache = exports.PortfolioCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
10
10
  // Core exports
11
11
  var base_cache_1 = require("./core/base-cache");
12
12
  Object.defineProperty(exports, "BaseCache", { enumerable: true, get: function () { return base_cache_1.BaseCache; } });
@@ -17,6 +17,8 @@ var balance_cache_1 = require("./stores/balance-cache");
17
17
  Object.defineProperty(exports, "BalanceCache", { enumerable: true, get: function () { return balance_cache_1.BalanceCache; } });
18
18
  var price_cache_1 = require("./stores/price-cache");
19
19
  Object.defineProperty(exports, "PriceCache", { enumerable: true, get: function () { return price_cache_1.PriceCache; } });
20
+ var portfolio_cache_1 = require("./stores/portfolio-cache");
21
+ Object.defineProperty(exports, "PortfolioCache", { enumerable: true, get: function () { return portfolio_cache_1.PortfolioCache; } });
20
22
  var transaction_cache_1 = require("./stores/transaction-cache");
21
23
  Object.defineProperty(exports, "TransactionCache", { enumerable: true, get: function () { return transaction_cache_1.TransactionCache; } });
22
24
  // Worker exports
@@ -21,7 +21,12 @@ export declare class BalanceCache extends BaseCache<BalanceData> {
21
21
  constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>);
22
22
  /**
23
23
  * Build Redis key for balance data
24
- * Format: balance_v2:caip:pubkey
24
+ * Format: balance_v2:caip:hashedPubkey
25
+ *
26
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
27
+ * - One-way: cannot recover pubkey from hash
28
+ * - Deterministic: same pubkey always produces same key
29
+ * - Fast: ~0.02ms per hash
25
30
  */
26
31
  protected buildKey(params: Record<string, any>): string;
27
32
  /**
@@ -39,6 +44,7 @@ export declare class BalanceCache extends BaseCache<BalanceData> {
39
44
  getBalance(caip: string, pubkey: string, waitForFresh?: boolean): Promise<BalanceData>;
40
45
  /**
41
46
  * Get balances for multiple assets (batch operation)
47
+ * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
42
48
  */
43
49
  getBatchBalances(items: Array<{
44
50
  caip: string;
@@ -5,10 +5,42 @@
5
5
  Extends BaseCache with balance-specific logic.
6
6
  All common logic is inherited from BaseCache.
7
7
  */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
8
11
  Object.defineProperty(exports, "__esModule", { value: true });
9
12
  exports.BalanceCache = void 0;
10
13
  const base_cache_1 = require("../core/base-cache");
14
+ const crypto_1 = __importDefault(require("crypto"));
11
15
  const log = require('@pioneer-platform/loggerdog')();
16
+ /**
17
+ * Hash pubkey/xpub for privacy-protecting cache keys
18
+ * Uses SHA-256 for fast, deterministic, one-way hashing
19
+ */
20
+ function hashPubkey(pubkey, salt = '') {
21
+ // Detect if this is an xpub (don't lowercase) or regular address (lowercase)
22
+ const isXpub = pubkey.match(/^[xyz]pub[1-9A-HJ-NP-Za-km-z]{100,}/);
23
+ const normalized = isXpub ? pubkey.trim() : pubkey.trim().toLowerCase();
24
+ const hash = crypto_1.default.createHash('sha256');
25
+ hash.update(normalized);
26
+ if (salt)
27
+ hash.update(salt);
28
+ // Return first 128 bits (32 hex chars) for shorter Redis keys
29
+ return hash.digest('hex').substring(0, 32);
30
+ }
31
+ /**
32
+ * Sanitize pubkey for safe logging
33
+ */
34
+ function sanitizePubkey(pubkey) {
35
+ if (!pubkey || pubkey.length < 12)
36
+ return '[invalid]';
37
+ // Check if xpub (show first 8, last 8)
38
+ if (pubkey.match(/^[xyz]pub/)) {
39
+ return `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`;
40
+ }
41
+ // Regular address (show first 6, last 4)
42
+ return `${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 4)}`;
43
+ }
12
44
  /**
13
45
  * BalanceCache - Caches blockchain balance data
14
46
  */
@@ -17,14 +49,14 @@ class BalanceCache extends base_cache_1.BaseCache {
17
49
  const defaultConfig = {
18
50
  name: 'balance',
19
51
  keyPrefix: 'balance_v2:',
20
- ttl: 5 * 60 * 1000, // 5 minutes
21
- staleThreshold: 2 * 60 * 1000, // 2 minutes
22
- enableTTL: true,
23
- queueName: 'balance-refresh-v2',
52
+ ttl: 0, // Ignored when enableTTL: false
53
+ staleThreshold: 5 * 60 * 1000, // 5 minutes - triggers background refresh
54
+ enableTTL: false, // NEVER EXPIRE - data persists forever
55
+ queueName: 'cache-refresh',
24
56
  enableQueue: true,
25
57
  maxRetries: 3,
26
58
  retryDelay: 10000,
27
- blockOnMiss: true, // Wait for fresh data on first request
59
+ blockOnMiss: true, // Wait for fresh data on first request - users need real balances!
28
60
  enableLegacyFallback: true,
29
61
  defaultValue: {
30
62
  caip: '',
@@ -42,7 +74,12 @@ class BalanceCache extends base_cache_1.BaseCache {
42
74
  }
43
75
  /**
44
76
  * Build Redis key for balance data
45
- * Format: balance_v2:caip:pubkey
77
+ * Format: balance_v2:caip:hashedPubkey
78
+ *
79
+ * PRIVACY: Uses SHA-256 hash of pubkey/xpub instead of plaintext
80
+ * - One-way: cannot recover pubkey from hash
81
+ * - Deterministic: same pubkey always produces same key
82
+ * - Fast: ~0.02ms per hash
46
83
  */
47
84
  buildKey(params) {
48
85
  const { caip, pubkey } = params;
@@ -50,9 +87,9 @@ class BalanceCache extends base_cache_1.BaseCache {
50
87
  throw new Error('BalanceCache.buildKey: caip and pubkey required');
51
88
  }
52
89
  const normalizedCaip = caip.toLowerCase();
53
- // Don't lowercase pubkeys - xpub/ypub/zpub are case-sensitive base58
54
- const normalizedPubkey = pubkey;
55
- return `${this.config.keyPrefix}${normalizedCaip}:${normalizedPubkey}`;
90
+ // Hash pubkey for privacy protection
91
+ const hashedPubkey = hashPubkey(pubkey, caip);
92
+ return `${this.config.keyPrefix}${normalizedCaip}:${hashedPubkey}`;
56
93
  }
57
94
  /**
58
95
  * Fetch balance from blockchain via balance module
@@ -61,12 +98,14 @@ class BalanceCache extends base_cache_1.BaseCache {
61
98
  const tag = this.TAG + 'fetchFromSource | ';
62
99
  try {
63
100
  const { caip, pubkey } = params;
64
- // Fetch balance using balance module
101
+ // Log sanitized pubkey for debugging
102
+ log.debug(tag, `Fetching balance for ${caip}/${sanitizePubkey(pubkey)}`);
103
+ // Fetch balance using balance module (still uses real pubkey)
65
104
  const asset = { caip };
66
105
  const owner = { pubkey };
67
106
  const balanceInfo = await this.balanceModule.getBalance(asset, owner);
68
107
  if (!balanceInfo || !balanceInfo.balance) {
69
- log.warn(tag, `No balance returned for ${caip}/${pubkey.substring(0, 10)}...`);
108
+ log.warn(tag, `No balance returned for ${caip}/${sanitizePubkey(pubkey)}`);
70
109
  return {
71
110
  caip,
72
111
  pubkey,
@@ -131,17 +170,70 @@ class BalanceCache extends base_cache_1.BaseCache {
131
170
  }
132
171
  /**
133
172
  * Get balances for multiple assets (batch operation)
173
+ * OPTIMIZED: Uses Redis MGET for single round-trip instead of N individual GETs
134
174
  */
135
175
  async getBatchBalances(items, waitForFresh) {
136
176
  const tag = this.TAG + 'getBatchBalances | ';
137
177
  const startTime = Date.now();
138
178
  try {
139
- log.info(tag, `Batch request for ${items.length} balances`);
140
- // Get all balances in parallel
141
- const promises = items.map(item => this.getBalance(item.caip, item.pubkey, waitForFresh));
142
- const results = await Promise.all(promises);
179
+ log.info(tag, `Batch request for ${items.length} balances using Redis MGET`);
180
+ // Build all Redis keys
181
+ const keys = items.map(item => this.buildKey({ caip: item.caip, pubkey: item.pubkey }));
182
+ // PERF: Use MGET to fetch all keys in ONE Redis round-trip
183
+ const cachedValues = await this.redis.mget(...keys);
184
+ // Process results
185
+ const results = [];
186
+ const missedItems = [];
187
+ for (let i = 0; i < items.length; i++) {
188
+ const item = items[i];
189
+ const cached = cachedValues[i];
190
+ if (cached) {
191
+ try {
192
+ const parsed = JSON.parse(cached);
193
+ if (parsed.value && parsed.value.caip && parsed.value.pubkey) {
194
+ results[i] = parsed.value;
195
+ continue;
196
+ }
197
+ }
198
+ catch (e) {
199
+ log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
200
+ }
201
+ }
202
+ // Cache miss - record for fetching
203
+ missedItems.push({ ...item, index: i });
204
+ results[i] = this.config.defaultValue; // Placeholder
205
+ }
143
206
  const responseTime = Date.now() - startTime;
144
- log.info(tag, `Batch completed: ${results.length} balances in ${responseTime}ms (${(responseTime / results.length).toFixed(1)}ms avg)`);
207
+ const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
208
+ log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
209
+ // If we have cache misses and blocking is enabled, fetch them
210
+ if (missedItems.length > 0) {
211
+ const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
212
+ if (shouldBlock) {
213
+ log.info(tag, `Fetching ${missedItems.length} cache misses...`);
214
+ const fetchStart = Date.now();
215
+ // Fetch all misses in parallel
216
+ const fetchPromises = missedItems.map(async (item) => {
217
+ try {
218
+ // Use fetchFresh to ensure Redis is updated and requests are deduplicated
219
+ const freshData = await this.fetchFresh({ caip: item.caip, pubkey: item.pubkey });
220
+ results[item.index] = freshData;
221
+ }
222
+ catch (error) {
223
+ log.error(tag, `Failed to fetch ${item.caip}/${item.pubkey}:`, error);
224
+ results[item.index] = { caip: item.caip, pubkey: item.pubkey, balance: '0' };
225
+ }
226
+ });
227
+ await Promise.all(fetchPromises);
228
+ log.info(tag, `Fetched ${missedItems.length} misses in ${Date.now() - fetchStart}ms`);
229
+ }
230
+ else {
231
+ // Non-blocking: trigger background refresh for misses
232
+ missedItems.forEach(item => {
233
+ this.triggerAsyncRefresh({ caip: item.caip, pubkey: item.pubkey }, 'high');
234
+ });
235
+ }
236
+ }
145
237
  return results;
146
238
  }
147
239
  catch (error) {
@@ -0,0 +1,79 @@
1
+ import { BaseCache } from '../core/base-cache';
2
+ import type { CacheConfig } from '../types';
3
+ /**
4
+ * Portfolio chart data structure
5
+ * Represents a single asset balance with pricing for charts
6
+ */
7
+ export interface ChartData {
8
+ caip: string;
9
+ pubkey: string;
10
+ networkId: string;
11
+ symbol: string;
12
+ name: string;
13
+ balance: string;
14
+ priceUsd: number;
15
+ valueUsd: number;
16
+ icon?: string;
17
+ type?: string;
18
+ decimal?: number;
19
+ }
20
+ /**
21
+ * Full portfolio data for a pubkey set
22
+ */
23
+ export interface PortfolioData {
24
+ pubkeys: Array<{
25
+ pubkey: string;
26
+ caip: string;
27
+ }>;
28
+ charts: ChartData[];
29
+ totalValueUsd: number;
30
+ timestamp: number;
31
+ }
32
+ /**
33
+ * PortfolioCache - Caches portfolio/chart data
34
+ *
35
+ * CRITICAL: This cache is NON-BLOCKING by design
36
+ * - Returns empty arrays immediately on cache miss
37
+ * - Never blocks waiting for blockchain APIs
38
+ * - Background jobs populate cache for next request
39
+ */
40
+ export declare class PortfolioCache extends BaseCache<PortfolioData> {
41
+ private balanceModule;
42
+ private marketsModule;
43
+ constructor(redis: any, balanceModule: any, marketsModule: any, config?: Partial<CacheConfig>);
44
+ /**
45
+ * Build Redis key for portfolio data
46
+ *
47
+ * Key strategy: Hash all pubkeys+caips to create a stable identifier
48
+ * Format: portfolio_v2:hash(pubkeys)
49
+ *
50
+ * This allows caching the same portfolio regardless of pubkey order
51
+ */
52
+ protected buildKey(params: Record<string, any>): string;
53
+ /**
54
+ * Simple hash function for cache keys
55
+ * Not cryptographic - just needs to be stable and collision-resistant
56
+ */
57
+ private simpleHash;
58
+ /**
59
+ * Fetch portfolio from blockchain APIs
60
+ *
61
+ * This is the SLOW operation that happens in the background
62
+ * It fetches balances for all pubkeys and enriches with pricing
63
+ */
64
+ protected fetchFromSource(params: Record<string, any>): Promise<PortfolioData>;
65
+ /**
66
+ * No legacy cache format for portfolios
67
+ */
68
+ protected getLegacyCached(params: Record<string, any>): Promise<PortfolioData | null>;
69
+ /**
70
+ * Get portfolio for a set of pubkeys
71
+ * Convenience method that wraps base get()
72
+ *
73
+ * RETURNS INSTANTLY - either cached data or empty arrays
74
+ */
75
+ getPortfolio(pubkeys: Array<{
76
+ pubkey: string;
77
+ caip: string;
78
+ }>, waitForFresh?: boolean): Promise<PortfolioData>;
79
+ }