@pioneer-platform/pioneer-cache 1.0.0

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.
@@ -0,0 +1,493 @@
1
+ "use strict";
2
+ /*
3
+ BaseCache - Abstract base class for all cache implementations
4
+
5
+ Contains all shared logic that was duplicated across balance/price caches.
6
+ Specific caches extend this and implement only their unique logic.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.BaseCache = void 0;
10
+ const log = require('@pioneer-platform/loggerdog')();
11
+ class BaseCache {
12
+ constructor(redis, config) {
13
+ this.queueInitialized = false;
14
+ // Cache for stats (to avoid expensive SCAN operations on every health check)
15
+ this.cachedStats = null;
16
+ this.cachedStatsTimestamp = 0;
17
+ this.STATS_CACHE_TTL = 30000; // 30 seconds
18
+ this.redis = redis;
19
+ this.config = config;
20
+ this.TAG = ` | ${config.name}Cache | `;
21
+ // Initialize queue if enabled
22
+ if (config.enableQueue) {
23
+ this.initializeQueue();
24
+ }
25
+ else {
26
+ log.info(this.TAG, `Queue disabled for ${config.name} cache`);
27
+ }
28
+ }
29
+ /**
30
+ * Initialize Redis queue for background refresh
31
+ */
32
+ initializeQueue() {
33
+ try {
34
+ const redisQueueModule = require('@pioneer-platform/redis-queue');
35
+ redisQueueModule.init(this.config.queueName);
36
+ this.redisQueue = redisQueueModule;
37
+ this.queueInitialized = true;
38
+ log.info(this.TAG, `✅ Queue initialized: ${this.config.queueName}`);
39
+ }
40
+ catch (error) {
41
+ // CRITICAL FIX #3: Make queue failures VERY visible
42
+ log.error(this.TAG, `❌ CRITICAL: Failed to initialize queue '${this.config.queueName}'!`);
43
+ log.error(this.TAG, 'Background refresh is DISABLED - cache will NOT update automatically!');
44
+ log.error(this.TAG, 'Error:', error);
45
+ this.redisQueue = null;
46
+ this.queueInitialized = false;
47
+ }
48
+ }
49
+ /**
50
+ * Main cache get method
51
+ * Implements stale-while-revalidate pattern with optional blocking
52
+ */
53
+ async get(params, waitForFresh) {
54
+ const tag = this.TAG + 'get | ';
55
+ const startTime = Date.now();
56
+ try {
57
+ const key = this.buildKey(params);
58
+ // Step 1: Try new cache format
59
+ const cachedValue = await this.getCached(key);
60
+ if (cachedValue) {
61
+ const age = Date.now() - cachedValue.timestamp;
62
+ const responseTime = Date.now() - startTime;
63
+ if (this.config.logCacheHits) {
64
+ log.debug(tag, `Cache hit: ${key} (${responseTime}ms, age: ${age}ms)`);
65
+ }
66
+ // Check staleness async (don't wait)
67
+ if (this.config.staleThreshold && age > this.config.staleThreshold) {
68
+ this.triggerAsyncRefresh(params, 'normal');
69
+ }
70
+ return {
71
+ success: true,
72
+ value: cachedValue.value,
73
+ cached: true,
74
+ fresh: this.config.staleThreshold ? age <= this.config.staleThreshold : true,
75
+ age
76
+ };
77
+ }
78
+ // Step 2: Try legacy cache fallback
79
+ if (this.config.enableLegacyFallback) {
80
+ const legacyValue = await this.getLegacyCached(params);
81
+ if (legacyValue) {
82
+ const responseTime = Date.now() - startTime;
83
+ log.info(tag, `Legacy cache hit: ${key} (${responseTime}ms)`);
84
+ // Migrate to new format (async)
85
+ this.migrateLegacyValue(key, legacyValue);
86
+ return {
87
+ success: true,
88
+ value: legacyValue,
89
+ cached: true,
90
+ fresh: false, // Legacy data is considered stale
91
+ age: undefined
92
+ };
93
+ }
94
+ }
95
+ // Step 3: Cache miss
96
+ const responseTime = Date.now() - startTime;
97
+ if (this.config.logCacheMisses) {
98
+ log.info(tag, `Cache miss: ${key} (${responseTime}ms)`);
99
+ }
100
+ // FIX #1: Optional blocking on cache miss
101
+ const shouldBlock = waitForFresh !== undefined ? waitForFresh : this.config.blockOnMiss;
102
+ if (shouldBlock) {
103
+ log.info(tag, `Blocking to fetch fresh data: ${key}`);
104
+ const freshValue = await this.fetchFresh(params);
105
+ return {
106
+ success: true,
107
+ value: freshValue,
108
+ cached: false,
109
+ fresh: true,
110
+ age: 0
111
+ };
112
+ }
113
+ // Non-blocking: trigger async refresh and return default
114
+ this.triggerAsyncRefresh(params, 'high');
115
+ return {
116
+ success: true,
117
+ value: this.config.defaultValue,
118
+ cached: false,
119
+ fresh: false,
120
+ age: undefined
121
+ };
122
+ }
123
+ catch (error) {
124
+ log.error(tag, 'Error getting cache value:', error);
125
+ return {
126
+ success: false,
127
+ value: this.config.defaultValue,
128
+ cached: false,
129
+ fresh: false,
130
+ error: error instanceof Error ? error.message : String(error)
131
+ };
132
+ }
133
+ }
134
+ /**
135
+ * Get value from cache
136
+ */
137
+ async getCached(key) {
138
+ const tag = this.TAG + 'getCached | ';
139
+ try {
140
+ const cached = await this.redis.get(key);
141
+ if (!cached) {
142
+ return null;
143
+ }
144
+ const parsed = JSON.parse(cached);
145
+ // Validate structure
146
+ if (!parsed.value || typeof parsed.timestamp !== 'number') {
147
+ log.warn(tag, `Invalid cache structure for ${key}, removing`);
148
+ await this.redis.del(key);
149
+ return null;
150
+ }
151
+ return parsed;
152
+ }
153
+ catch (error) {
154
+ log.error(tag, `Error parsing cached value for ${key}:`, error);
155
+ return null;
156
+ }
157
+ }
158
+ /**
159
+ * Update cache with new value
160
+ * FIX #2: Always includes TTL
161
+ */
162
+ async updateCache(key, value, metadata) {
163
+ const tag = this.TAG + 'updateCache | ';
164
+ try {
165
+ const cachedValue = {
166
+ value,
167
+ timestamp: Date.now(),
168
+ source: 'network',
169
+ lastUpdated: new Date().toISOString(),
170
+ metadata
171
+ };
172
+ // FIX #2: Always set TTL (unless explicitly disabled)
173
+ if (this.config.enableTTL) {
174
+ const ttlSeconds = Math.floor(this.config.ttl / 1000);
175
+ await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
176
+ log.info(tag, `Updated cache: ${key} [TTL: ${ttlSeconds}s]`);
177
+ }
178
+ else {
179
+ // Permanent caching (for transactions)
180
+ await this.redis.set(key, JSON.stringify(cachedValue));
181
+ log.info(tag, `Updated cache: ${key} [PERMANENT]`);
182
+ }
183
+ }
184
+ catch (error) {
185
+ log.error(tag, `Error updating cache for ${key}:`, error);
186
+ throw error;
187
+ }
188
+ }
189
+ /**
190
+ * Trigger background refresh job
191
+ * FIX #3: Loud error logging
192
+ * FIX #4: Synchronous fallback for high-priority
193
+ */
194
+ triggerAsyncRefresh(params, priority = 'normal') {
195
+ const tag = this.TAG + 'triggerAsyncRefresh | ';
196
+ try {
197
+ // Check if queue is enabled
198
+ if (!this.config.enableQueue) {
199
+ return; // Queue disabled, no background refresh
200
+ }
201
+ // FIX #3: Fail loudly if queue not initialized
202
+ if (!this.queueInitialized || !this.redisQueue) {
203
+ const key = this.buildKey(params);
204
+ log.error(tag, `❌ QUEUE NOT INITIALIZED! Cannot refresh ${key}`);
205
+ log.error(tag, `Background refresh is BROKEN - cache will NOT update!`);
206
+ // FIX #4: Synchronous fallback for high-priority
207
+ if (priority === 'high') {
208
+ log.warn(tag, `Using synchronous fallback for high-priority refresh`);
209
+ setImmediate(async () => {
210
+ try {
211
+ await this.fetchFresh(params);
212
+ }
213
+ catch (error) {
214
+ log.error(tag, `Synchronous fallback failed:`, error);
215
+ }
216
+ });
217
+ }
218
+ return;
219
+ }
220
+ const job = {
221
+ type: `REFRESH_${this.config.name.toUpperCase()}`,
222
+ key: this.buildKey(params),
223
+ params,
224
+ priority,
225
+ retryCount: 0,
226
+ timestamp: Date.now()
227
+ };
228
+ // Queue job async (don't wait)
229
+ setImmediate(async () => {
230
+ try {
231
+ await this.redisQueue.createWork(this.config.queueName, job);
232
+ if (this.config.logRefreshJobs) {
233
+ log.debug(tag, `Queued refresh job: ${job.key} (priority: ${priority})`);
234
+ }
235
+ }
236
+ catch (error) {
237
+ log.error(tag, `Error queuing refresh job:`, error);
238
+ }
239
+ });
240
+ }
241
+ catch (error) {
242
+ log.error(tag, `Error triggering refresh:`, error);
243
+ }
244
+ }
245
+ /**
246
+ * Fetch fresh data and update cache
247
+ * FIX #1 & #4: Used for blocking requests and fallback
248
+ */
249
+ async fetchFresh(params) {
250
+ const tag = this.TAG + 'fetchFresh | ';
251
+ const startTime = Date.now();
252
+ try {
253
+ const key = this.buildKey(params);
254
+ log.info(tag, `Fetching fresh data: ${key}`);
255
+ // Call subclass-specific fetch implementation
256
+ const value = await this.fetchFromSource(params);
257
+ // Update cache
258
+ await this.updateCache(key, value);
259
+ const fetchTime = Date.now() - startTime;
260
+ log.info(tag, `✅ Fetched fresh data in ${fetchTime}ms: ${key}`);
261
+ return value;
262
+ }
263
+ catch (error) {
264
+ const fetchTime = Date.now() - startTime;
265
+ log.error(tag, `Failed to fetch fresh data after ${fetchTime}ms:`, error);
266
+ return this.config.defaultValue;
267
+ }
268
+ }
269
+ /**
270
+ * Migrate legacy cache value to new format
271
+ * FIX #2: Includes TTL
272
+ */
273
+ migrateLegacyValue(key, value) {
274
+ const tag = this.TAG + 'migrateLegacyValue | ';
275
+ setImmediate(async () => {
276
+ try {
277
+ const cachedValue = {
278
+ value,
279
+ timestamp: Date.now() - (this.config.staleThreshold || this.config.ttl), // Mark as stale
280
+ source: 'legacy',
281
+ lastUpdated: new Date().toISOString()
282
+ };
283
+ // FIX #2: Set TTL on migrated data
284
+ if (this.config.enableTTL) {
285
+ const ttlSeconds = Math.floor(this.config.ttl / 1000);
286
+ await this.redis.set(key, JSON.stringify(cachedValue), 'EX', ttlSeconds);
287
+ log.debug(tag, `Migrated legacy value: ${key} [TTL: ${ttlSeconds}s]`);
288
+ }
289
+ else {
290
+ await this.redis.set(key, JSON.stringify(cachedValue));
291
+ log.debug(tag, `Migrated legacy value: ${key} [PERMANENT]`);
292
+ }
293
+ }
294
+ catch (error) {
295
+ log.error(tag, `Error migrating legacy value:`, error);
296
+ }
297
+ });
298
+ }
299
+ /**
300
+ * Get cache statistics
301
+ * @param forceRefresh - Skip cache and fetch fresh stats (for testing/debugging)
302
+ */
303
+ async getCacheStats(forceRefresh = false) {
304
+ const tag = this.TAG + 'getCacheStats | ';
305
+ // Return cached stats if fresh (unless forceRefresh requested)
306
+ if (!forceRefresh && this.cachedStats && this.cachedStatsTimestamp) {
307
+ const age = Date.now() - this.cachedStatsTimestamp;
308
+ if (age < this.STATS_CACHE_TTL) {
309
+ log.debug(tag, `Returning cached stats (age: ${age}ms)`);
310
+ return this.cachedStats;
311
+ }
312
+ }
313
+ try {
314
+ const pattern = this.config.keyPrefix + '*';
315
+ const keys = [];
316
+ // Use SCAN instead of KEYS for non-blocking iteration
317
+ // SCAN is production-safe and doesn't block Redis
318
+ let cursor = '0';
319
+ const maxKeys = 20; // Sample up to 20 keys for stats (performance)
320
+ do {
321
+ // SCAN with MATCH pattern, COUNT 50 per iteration for faster collection
322
+ const result = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 50);
323
+ cursor = result[0]; // Next cursor position
324
+ const foundKeys = result[1]; // Array of matching keys
325
+ keys.push(...foundKeys);
326
+ // Stop if we have enough samples or completed scan
327
+ if (keys.length >= maxKeys || cursor === '0') {
328
+ break;
329
+ }
330
+ } while (cursor !== '0');
331
+ let totalEntries = 0;
332
+ let staleEntries = 0;
333
+ let entriesWithoutTTL = 0;
334
+ const sources = {};
335
+ // Analyze sampled keys
336
+ const sampled = keys.slice(0, maxKeys);
337
+ for (const key of sampled) {
338
+ try {
339
+ const cached = await this.redis.get(key);
340
+ if (cached) {
341
+ const parsed = JSON.parse(cached);
342
+ totalEntries++;
343
+ // Check staleness
344
+ if (this.config.staleThreshold) {
345
+ const age = Date.now() - parsed.timestamp;
346
+ if (age > this.config.staleThreshold) {
347
+ staleEntries++;
348
+ }
349
+ }
350
+ // Track sources
351
+ if (parsed.source) {
352
+ sources[parsed.source] = (sources[parsed.source] || 0) + 1;
353
+ }
354
+ // Check TTL
355
+ if (this.config.enableTTL) {
356
+ const ttl = await this.redis.ttl(key);
357
+ if (ttl === -1) {
358
+ entriesWithoutTTL++;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ catch (parseError) {
364
+ log.warn(tag, `Invalid cache entry: ${key}`);
365
+ }
366
+ }
367
+ const freshEntries = totalEntries - staleEntries;
368
+ const stalenessRate = totalEntries > 0
369
+ ? ((staleEntries / totalEntries) * 100).toFixed(1) + '%'
370
+ : '0%';
371
+ const stats = {
372
+ totalEntries: keys.length, // Sampled keys count (≤20)
373
+ staleEntries,
374
+ freshEntries,
375
+ stalenessRate,
376
+ sources,
377
+ entriesWithoutTTL: this.config.enableTTL ? entriesWithoutTTL : undefined,
378
+ ttl: this.config.ttl,
379
+ staleThreshold: this.config.staleThreshold
380
+ };
381
+ // Cache the stats for future requests
382
+ this.cachedStats = stats;
383
+ this.cachedStatsTimestamp = Date.now();
384
+ log.debug(tag, `Stats refreshed and cached`);
385
+ return stats;
386
+ }
387
+ catch (error) {
388
+ log.error(tag, 'Error getting cache stats:', error);
389
+ return {
390
+ totalEntries: 0,
391
+ staleEntries: 0,
392
+ freshEntries: 0,
393
+ stalenessRate: '0%'
394
+ };
395
+ }
396
+ }
397
+ /**
398
+ * FIX #5: Health check
399
+ * @param forceRefresh - Force refresh stats (bypasses 30s cache)
400
+ */
401
+ async getHealth(forceRefresh = false) {
402
+ const tag = this.TAG + 'getHealth | ';
403
+ try {
404
+ const issues = [];
405
+ const warnings = [];
406
+ // Check queue initialization
407
+ if (this.config.enableQueue && !this.queueInitialized) {
408
+ issues.push('Queue not initialized - background refresh disabled');
409
+ }
410
+ // Check Redis connection
411
+ try {
412
+ await this.redis.ping();
413
+ }
414
+ catch (error) {
415
+ issues.push('Redis connection failed');
416
+ }
417
+ // Get cache stats (with optional force refresh)
418
+ const stats = await this.getCacheStats(forceRefresh);
419
+ // Check for entries without TTL
420
+ if (stats.entriesWithoutTTL && stats.entriesWithoutTTL > 0) {
421
+ issues.push(`${stats.entriesWithoutTTL} cache entries without TTL detected`);
422
+ }
423
+ // Check staleness rate
424
+ if (this.config.staleThreshold) {
425
+ const stalenessRate = parseFloat(String(stats.stalenessRate).replace('%', ''));
426
+ if (stalenessRate > 50) {
427
+ issues.push(`High staleness rate: ${stalenessRate}%`);
428
+ }
429
+ else if (stalenessRate > 30) {
430
+ warnings.push(`Elevated staleness rate: ${stalenessRate}%`);
431
+ }
432
+ }
433
+ // Determine overall status
434
+ let status = 'healthy';
435
+ if (issues.length > 0) {
436
+ status = 'unhealthy';
437
+ }
438
+ else if (warnings.length > 0) {
439
+ status = 'degraded';
440
+ }
441
+ return {
442
+ status,
443
+ queueInitialized: this.queueInitialized,
444
+ redisConnected: true,
445
+ stats,
446
+ issues,
447
+ warnings,
448
+ timestamp: Date.now(),
449
+ timestampISO: new Date().toISOString()
450
+ };
451
+ }
452
+ catch (error) {
453
+ log.error(tag, 'Health check failed:', error);
454
+ return {
455
+ status: 'unhealthy',
456
+ queueInitialized: this.queueInitialized,
457
+ redisConnected: false,
458
+ stats: {
459
+ totalEntries: 0,
460
+ staleEntries: 0,
461
+ freshEntries: 0,
462
+ stalenessRate: '0%'
463
+ },
464
+ issues: [`Health check error: ${error instanceof Error ? error.message : String(error)}`],
465
+ warnings: [],
466
+ timestamp: Date.now(),
467
+ timestampISO: new Date().toISOString()
468
+ };
469
+ }
470
+ }
471
+ /**
472
+ * Clear all cache entries (use with caution)
473
+ */
474
+ async clearAll() {
475
+ const tag = this.TAG + 'clearAll | ';
476
+ try {
477
+ const pattern = this.config.keyPrefix + '*';
478
+ const keys = await this.redis.keys(pattern);
479
+ if (keys.length === 0) {
480
+ log.info(tag, 'No cache entries to clear');
481
+ return 0;
482
+ }
483
+ await this.redis.del(...keys);
484
+ log.info(tag, `Cleared ${keys.length} cache entries`);
485
+ return keys.length;
486
+ }
487
+ catch (error) {
488
+ log.error(tag, 'Error clearing cache:', error);
489
+ return 0;
490
+ }
491
+ }
492
+ }
493
+ exports.BaseCache = BaseCache;
@@ -0,0 +1,62 @@
1
+ import { BalanceCache } from '../stores/balance-cache';
2
+ import { PriceCache } from '../stores/price-cache';
3
+ import { TransactionCache } from '../stores/transaction-cache';
4
+ import type { HealthCheckResult } from '../types';
5
+ /**
6
+ * Configuration for CacheManager
7
+ */
8
+ export interface CacheManagerConfig {
9
+ redis: any;
10
+ balanceModule?: any;
11
+ markets?: any;
12
+ enableBalanceCache?: boolean;
13
+ enablePriceCache?: boolean;
14
+ enableTransactionCache?: boolean;
15
+ startWorkers?: boolean;
16
+ }
17
+ /**
18
+ * CacheManager - Central coordinator for all caches
19
+ */
20
+ export declare class CacheManager {
21
+ private redis;
22
+ private balanceCache?;
23
+ private priceCache?;
24
+ private transactionCache?;
25
+ private workers;
26
+ constructor(config: CacheManagerConfig);
27
+ /**
28
+ * Start background refresh workers for all caches
29
+ */
30
+ startWorkers(): Promise<void>;
31
+ /**
32
+ * Stop all workers gracefully
33
+ */
34
+ stopWorkers(): Promise<void>;
35
+ /**
36
+ * Get aggregate health status for all caches
37
+ * @param forceRefresh - Force refresh stats (bypasses 30s cache)
38
+ */
39
+ getHealth(forceRefresh?: boolean): Promise<HealthCheckResult & {
40
+ checks?: any;
41
+ }>;
42
+ /**
43
+ * Get all cache instances
44
+ */
45
+ getCaches(): {
46
+ balance: BalanceCache | undefined;
47
+ price: PriceCache | undefined;
48
+ transaction: TransactionCache | undefined;
49
+ };
50
+ /**
51
+ * Get specific cache by name
52
+ */
53
+ getCache(name: 'balance' | 'price' | 'transaction'): BalanceCache | PriceCache | TransactionCache | undefined;
54
+ /**
55
+ * Clear all caches (use with caution!)
56
+ */
57
+ clearAll(): Promise<{
58
+ balance?: number;
59
+ price?: number;
60
+ transaction?: number;
61
+ }>;
62
+ }