@pioneer-platform/pioneer-cache 1.1.3 → 1.1.5
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 +12 -0
- package/dist/core/cache-manager.d.ts +7 -1
- package/dist/core/cache-manager.js +27 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/stores/staking-cache.d.ts +75 -0
- package/dist/stores/staking-cache.js +229 -0
- package/package.json +1 -1
- package/src/core/cache-manager.ts +36 -3
- package/src/index.ts +2 -0
- package/src/stores/staking-cache.ts +302 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
[0m[2m[35m$[0m [2m[1mtsc[0m
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@ import { BalanceCache } from '../stores/balance-cache';
|
|
|
2
2
|
import { PriceCache } from '../stores/price-cache';
|
|
3
3
|
import { PortfolioCache } from '../stores/portfolio-cache';
|
|
4
4
|
import { TransactionCache } from '../stores/transaction-cache';
|
|
5
|
+
import { StakingCache } from '../stores/staking-cache';
|
|
5
6
|
import type { HealthCheckResult } from '../types';
|
|
6
7
|
/**
|
|
7
8
|
* Configuration for CacheManager
|
|
@@ -11,10 +12,12 @@ export interface CacheManagerConfig {
|
|
|
11
12
|
redisQueue?: any;
|
|
12
13
|
balanceModule?: any;
|
|
13
14
|
markets?: any;
|
|
15
|
+
networkModules?: Map<string, any>;
|
|
14
16
|
enableBalanceCache?: boolean;
|
|
15
17
|
enablePriceCache?: boolean;
|
|
16
18
|
enablePortfolioCache?: boolean;
|
|
17
19
|
enableTransactionCache?: boolean;
|
|
20
|
+
enableStakingCache?: boolean;
|
|
18
21
|
startWorkers?: boolean;
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
@@ -27,6 +30,7 @@ export declare class CacheManager {
|
|
|
27
30
|
private priceCache?;
|
|
28
31
|
private portfolioCache?;
|
|
29
32
|
private transactionCache?;
|
|
33
|
+
private stakingCache?;
|
|
30
34
|
private workers;
|
|
31
35
|
constructor(config: CacheManagerConfig);
|
|
32
36
|
/**
|
|
@@ -52,11 +56,12 @@ export declare class CacheManager {
|
|
|
52
56
|
price: PriceCache | undefined;
|
|
53
57
|
portfolio: PortfolioCache | undefined;
|
|
54
58
|
transaction: TransactionCache | undefined;
|
|
59
|
+
staking: StakingCache | undefined;
|
|
55
60
|
};
|
|
56
61
|
/**
|
|
57
62
|
* Get specific cache by name
|
|
58
63
|
*/
|
|
59
|
-
getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | undefined;
|
|
64
|
+
getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking'): BalanceCache | PriceCache | PortfolioCache | TransactionCache | StakingCache | undefined;
|
|
60
65
|
/**
|
|
61
66
|
* Clear all caches (use with caution!)
|
|
62
67
|
*/
|
|
@@ -65,5 +70,6 @@ export declare class CacheManager {
|
|
|
65
70
|
price?: number;
|
|
66
71
|
portfolio?: number;
|
|
67
72
|
transaction?: number;
|
|
73
|
+
staking?: number;
|
|
68
74
|
}>;
|
|
69
75
|
}
|
|
@@ -11,6 +11,7 @@ const balance_cache_1 = require("../stores/balance-cache");
|
|
|
11
11
|
const price_cache_1 = require("../stores/price-cache");
|
|
12
12
|
const portfolio_cache_1 = require("../stores/portfolio-cache");
|
|
13
13
|
const transaction_cache_1 = require("../stores/transaction-cache");
|
|
14
|
+
const staking_cache_1 = require("../stores/staking-cache");
|
|
14
15
|
const refresh_worker_1 = require("../workers/refresh-worker");
|
|
15
16
|
const log = require('@pioneer-platform/loggerdog')();
|
|
16
17
|
const TAG = ' | CacheManager | ';
|
|
@@ -42,6 +43,11 @@ class CacheManager {
|
|
|
42
43
|
this.transactionCache = new transaction_cache_1.TransactionCache(this.redis);
|
|
43
44
|
log.info(TAG, '✅ Transaction cache initialized');
|
|
44
45
|
}
|
|
46
|
+
// Initialize Staking Cache (with markets module for price enrichment)
|
|
47
|
+
if (config.enableStakingCache !== false && config.networkModules && config.networkModules.size > 0) {
|
|
48
|
+
this.stakingCache = new staking_cache_1.StakingCache(this.redis, config.networkModules, config.markets);
|
|
49
|
+
log.info(TAG, '✅ Staking cache initialized');
|
|
50
|
+
}
|
|
45
51
|
// Auto-start workers if requested
|
|
46
52
|
if (config.startWorkers) {
|
|
47
53
|
setImmediate(() => {
|
|
@@ -68,6 +74,9 @@ class CacheManager {
|
|
|
68
74
|
if (this.portfolioCache) {
|
|
69
75
|
cacheRegistry.set('portfolio', this.portfolioCache);
|
|
70
76
|
}
|
|
77
|
+
if (this.stakingCache) {
|
|
78
|
+
cacheRegistry.set('staking', this.stakingCache);
|
|
79
|
+
}
|
|
71
80
|
// Start unified worker if we have any caches with queues
|
|
72
81
|
if (cacheRegistry.size > 0) {
|
|
73
82
|
const worker = await (0, refresh_worker_1.startUnifiedWorker)(this.redisQueue, // Use dedicated queue client for blocking operations
|
|
@@ -155,6 +164,17 @@ class CacheManager {
|
|
|
155
164
|
stats: txStats
|
|
156
165
|
};
|
|
157
166
|
}
|
|
167
|
+
// Check staking cache
|
|
168
|
+
if (this.stakingCache) {
|
|
169
|
+
const stakingHealth = await this.stakingCache.getHealth(forceRefresh);
|
|
170
|
+
checks.staking = stakingHealth;
|
|
171
|
+
if (stakingHealth.status === 'unhealthy') {
|
|
172
|
+
issues.push(...stakingHealth.issues.map(i => `Staking: ${i}`));
|
|
173
|
+
}
|
|
174
|
+
else if (stakingHealth.status === 'degraded') {
|
|
175
|
+
warnings.push(...stakingHealth.warnings.map(w => `Staking: ${w}`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
158
178
|
// Check workers
|
|
159
179
|
for (const worker of this.workers) {
|
|
160
180
|
const workerStats = await worker.getStats();
|
|
@@ -215,7 +235,8 @@ class CacheManager {
|
|
|
215
235
|
balance: this.balanceCache,
|
|
216
236
|
price: this.priceCache,
|
|
217
237
|
portfolio: this.portfolioCache,
|
|
218
|
-
transaction: this.transactionCache
|
|
238
|
+
transaction: this.transactionCache,
|
|
239
|
+
staking: this.stakingCache
|
|
219
240
|
};
|
|
220
241
|
}
|
|
221
242
|
/**
|
|
@@ -231,6 +252,8 @@ class CacheManager {
|
|
|
231
252
|
return this.portfolioCache;
|
|
232
253
|
case 'transaction':
|
|
233
254
|
return this.transactionCache;
|
|
255
|
+
case 'staking':
|
|
256
|
+
return this.stakingCache;
|
|
234
257
|
default:
|
|
235
258
|
return undefined;
|
|
236
259
|
}
|
|
@@ -254,6 +277,9 @@ class CacheManager {
|
|
|
254
277
|
if (this.transactionCache) {
|
|
255
278
|
result.transaction = await this.transactionCache.clearAll();
|
|
256
279
|
}
|
|
280
|
+
if (this.stakingCache) {
|
|
281
|
+
result.staking = await this.stakingCache.clearAll();
|
|
282
|
+
}
|
|
257
283
|
log.info(tag, 'Cleared all caches:', result);
|
|
258
284
|
return result;
|
|
259
285
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,10 +4,12 @@ export { BalanceCache } from './stores/balance-cache';
|
|
|
4
4
|
export { PriceCache } from './stores/price-cache';
|
|
5
5
|
export { PortfolioCache } from './stores/portfolio-cache';
|
|
6
6
|
export { TransactionCache } from './stores/transaction-cache';
|
|
7
|
+
export { StakingCache } from './stores/staking-cache';
|
|
7
8
|
export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
|
|
8
9
|
export type { CacheConfig, CachedValue, CacheResult, RefreshJob, HealthCheckResult, CacheStats } from './types';
|
|
9
10
|
export type { BalanceData } from './stores/balance-cache';
|
|
10
11
|
export type { PriceData } from './stores/price-cache';
|
|
11
12
|
export type { PortfolioData, ChartData } from './stores/portfolio-cache';
|
|
13
|
+
export type { StakingPosition } from './stores/staking-cache';
|
|
12
14
|
export type { CacheManagerConfig } from './core/cache-manager';
|
|
13
15
|
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.PortfolioCache = exports.PriceCache = exports.BalanceCache = exports.CacheManager = exports.BaseCache = void 0;
|
|
9
|
+
exports.startUnifiedWorker = exports.RefreshWorker = exports.StakingCache = 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; } });
|
|
@@ -21,6 +21,8 @@ var portfolio_cache_1 = require("./stores/portfolio-cache");
|
|
|
21
21
|
Object.defineProperty(exports, "PortfolioCache", { enumerable: true, get: function () { return portfolio_cache_1.PortfolioCache; } });
|
|
22
22
|
var transaction_cache_1 = require("./stores/transaction-cache");
|
|
23
23
|
Object.defineProperty(exports, "TransactionCache", { enumerable: true, get: function () { return transaction_cache_1.TransactionCache; } });
|
|
24
|
+
var staking_cache_1 = require("./stores/staking-cache");
|
|
25
|
+
Object.defineProperty(exports, "StakingCache", { enumerable: true, get: function () { return staking_cache_1.StakingCache; } });
|
|
24
26
|
// Worker exports
|
|
25
27
|
var refresh_worker_1 = require("./workers/refresh-worker");
|
|
26
28
|
Object.defineProperty(exports, "RefreshWorker", { enumerable: true, get: function () { return refresh_worker_1.RefreshWorker; } });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BaseCache } from '../core/base-cache';
|
|
2
|
+
import type { CacheConfig } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Staking position data structure
|
|
5
|
+
*/
|
|
6
|
+
export interface StakingPosition {
|
|
7
|
+
type: 'delegation' | 'reward' | 'unbonding';
|
|
8
|
+
chart: 'staking';
|
|
9
|
+
context: string;
|
|
10
|
+
contextType: string;
|
|
11
|
+
caip: string;
|
|
12
|
+
networkId: string;
|
|
13
|
+
pubkey: string;
|
|
14
|
+
validatorAddress?: string;
|
|
15
|
+
validator?: string;
|
|
16
|
+
balance: number;
|
|
17
|
+
denom: string;
|
|
18
|
+
ticker: string;
|
|
19
|
+
symbol: string;
|
|
20
|
+
name: string;
|
|
21
|
+
icon: string;
|
|
22
|
+
status: 'active' | 'claimable' | 'unbonding';
|
|
23
|
+
completionTime?: string;
|
|
24
|
+
shares?: string;
|
|
25
|
+
priceUsd?: number;
|
|
26
|
+
valueUsd?: number;
|
|
27
|
+
updated: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Network module interface for staking
|
|
31
|
+
*/
|
|
32
|
+
interface NetworkModule {
|
|
33
|
+
getStakingPositions(address: string): Promise<StakingPosition[]>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
|
|
37
|
+
*/
|
|
38
|
+
export declare class StakingCache extends BaseCache<StakingPosition[]> {
|
|
39
|
+
private networkModules;
|
|
40
|
+
private markets;
|
|
41
|
+
constructor(redis: any, networkModules: Map<string, NetworkModule>, markets?: any, config?: Partial<CacheConfig>);
|
|
42
|
+
/**
|
|
43
|
+
* Build Redis key for staking data
|
|
44
|
+
* Format: staking_v1:networkId:address
|
|
45
|
+
*/
|
|
46
|
+
protected buildKey(params: Record<string, any>): string;
|
|
47
|
+
/**
|
|
48
|
+
* Fetch staking positions from blockchain via network module
|
|
49
|
+
* and enrich with pricing data from markets module
|
|
50
|
+
*/
|
|
51
|
+
protected fetchFromSource(params: Record<string, any>): Promise<StakingPosition[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Enrich staking positions with USD pricing
|
|
54
|
+
* Uses the markets module to fetch prices for the native tokens
|
|
55
|
+
*/
|
|
56
|
+
private enrichPositionsWithPricing;
|
|
57
|
+
/**
|
|
58
|
+
* No legacy cache format for staking (new feature)
|
|
59
|
+
*/
|
|
60
|
+
protected getLegacyCached(params: Record<string, any>): Promise<StakingPosition[] | null>;
|
|
61
|
+
/**
|
|
62
|
+
* Get staking positions for a specific network and address
|
|
63
|
+
* Convenience method that wraps base get()
|
|
64
|
+
*/
|
|
65
|
+
getStakingPositions(networkId: string, address: string, waitForFresh?: boolean): Promise<StakingPosition[]>;
|
|
66
|
+
/**
|
|
67
|
+
* Get staking positions for multiple addresses (batch operation)
|
|
68
|
+
* OPTIMIZED: Uses Redis MGET for single round-trip
|
|
69
|
+
*/
|
|
70
|
+
getBatchStakingPositions(items: Array<{
|
|
71
|
+
networkId: string;
|
|
72
|
+
address: string;
|
|
73
|
+
}>, waitForFresh?: boolean): Promise<Map<string, StakingPosition[]>>;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
StakingCache - Staking position cache implementation
|
|
4
|
+
|
|
5
|
+
Extends BaseCache with staking-specific logic for Cosmos-based chains.
|
|
6
|
+
Caches delegation, reward, and unbonding positions.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.StakingCache = void 0;
|
|
10
|
+
const base_cache_1 = require("../core/base-cache");
|
|
11
|
+
const log = require('@pioneer-platform/loggerdog')();
|
|
12
|
+
const TAG = ' | StakingCache | ';
|
|
13
|
+
/**
|
|
14
|
+
* StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
|
|
15
|
+
*/
|
|
16
|
+
class StakingCache extends base_cache_1.BaseCache {
|
|
17
|
+
constructor(redis, networkModules, markets, config) {
|
|
18
|
+
const defaultConfig = {
|
|
19
|
+
name: 'staking',
|
|
20
|
+
keyPrefix: 'staking_v1:',
|
|
21
|
+
ttl: 5 * 60 * 1000, // 5 minutes - staking changes slowly
|
|
22
|
+
staleThreshold: 2 * 60 * 1000, // Refresh after 2 minutes
|
|
23
|
+
enableTTL: true, // Enable expiration
|
|
24
|
+
queueName: 'cache-refresh',
|
|
25
|
+
enableQueue: true,
|
|
26
|
+
maxRetries: 3,
|
|
27
|
+
retryDelay: 10000,
|
|
28
|
+
blockOnMiss: false, // Return [] immediately, fetch async
|
|
29
|
+
enableLegacyFallback: false, // No legacy format
|
|
30
|
+
defaultValue: [], // Empty array for no positions
|
|
31
|
+
maxConcurrentJobs: 10,
|
|
32
|
+
apiTimeout: 30000, // 30s timeout for blockchain API
|
|
33
|
+
logCacheHits: false,
|
|
34
|
+
logCacheMisses: true,
|
|
35
|
+
logRefreshJobs: true
|
|
36
|
+
};
|
|
37
|
+
super(redis, { ...defaultConfig, ...config });
|
|
38
|
+
this.networkModules = networkModules;
|
|
39
|
+
this.markets = markets;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build Redis key for staking data
|
|
43
|
+
* Format: staking_v1:networkId:address
|
|
44
|
+
*/
|
|
45
|
+
buildKey(params) {
|
|
46
|
+
const { networkId, address } = params;
|
|
47
|
+
if (!networkId || !address) {
|
|
48
|
+
throw new Error('StakingCache.buildKey: networkId and address required');
|
|
49
|
+
}
|
|
50
|
+
const normalizedNetworkId = networkId.toLowerCase();
|
|
51
|
+
const normalizedAddress = address.toLowerCase();
|
|
52
|
+
return `${this.config.keyPrefix}${normalizedNetworkId}:${normalizedAddress}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fetch staking positions from blockchain via network module
|
|
56
|
+
* and enrich with pricing data from markets module
|
|
57
|
+
*/
|
|
58
|
+
async fetchFromSource(params) {
|
|
59
|
+
const tag = this.TAG + 'fetchFromSource | ';
|
|
60
|
+
try {
|
|
61
|
+
const { networkId, address } = params;
|
|
62
|
+
log.debug(tag, `Fetching staking positions for ${address} on ${networkId}`);
|
|
63
|
+
// Get the appropriate network module
|
|
64
|
+
const networkModule = this.networkModules.get(networkId);
|
|
65
|
+
if (!networkModule) {
|
|
66
|
+
log.warn(tag, `No network module found for ${networkId}`);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
// Fetch staking positions from network module (raw blockchain data)
|
|
70
|
+
const positions = await networkModule.getStakingPositions(address);
|
|
71
|
+
if (!positions || !Array.isArray(positions)) {
|
|
72
|
+
log.warn(tag, `Invalid positions returned for ${networkId}/${address}`);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
log.info(tag, `Found ${positions.length} staking positions for ${networkId}/${address}`);
|
|
76
|
+
// Enrich with pricing data if markets module is available
|
|
77
|
+
if (this.markets && positions.length > 0) {
|
|
78
|
+
await this.enrichPositionsWithPricing(positions);
|
|
79
|
+
}
|
|
80
|
+
else if (!this.markets) {
|
|
81
|
+
log.warn(tag, 'Markets module not available, positions will have no pricing');
|
|
82
|
+
}
|
|
83
|
+
return positions;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
log.error(tag, 'Error fetching staking positions:', error);
|
|
87
|
+
// Return empty array instead of throwing - staking is optional
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Enrich staking positions with USD pricing
|
|
93
|
+
* Uses the markets module to fetch prices for the native tokens
|
|
94
|
+
*/
|
|
95
|
+
async enrichPositionsWithPricing(positions) {
|
|
96
|
+
const tag = TAG + 'enrichPositionsWithPricing | ';
|
|
97
|
+
try {
|
|
98
|
+
// Collect unique CAIPs from all positions
|
|
99
|
+
const uniqueCAIPs = [...new Set(positions.map(p => p.caip))];
|
|
100
|
+
log.debug(tag, `Fetching prices for ${uniqueCAIPs.length} unique assets:`, uniqueCAIPs);
|
|
101
|
+
// Batch fetch prices from markets module
|
|
102
|
+
const prices = {};
|
|
103
|
+
for (const caip of uniqueCAIPs) {
|
|
104
|
+
try {
|
|
105
|
+
const price = await this.markets.getAssetPriceByCaip(caip);
|
|
106
|
+
prices[caip] = price || 0;
|
|
107
|
+
log.debug(tag, `Price for ${caip}: $${price}`);
|
|
108
|
+
}
|
|
109
|
+
catch (priceError) {
|
|
110
|
+
log.error(tag, `Error fetching price for ${caip}:`, priceError);
|
|
111
|
+
prices[caip] = 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Enrich each position with its price
|
|
115
|
+
for (const position of positions) {
|
|
116
|
+
const priceUsd = prices[position.caip] || 0;
|
|
117
|
+
position.priceUsd = priceUsd;
|
|
118
|
+
position.valueUsd = position.balance * priceUsd;
|
|
119
|
+
log.debug(tag, `Enriched ${position.type} position:`, {
|
|
120
|
+
caip: position.caip,
|
|
121
|
+
balance: position.balance,
|
|
122
|
+
priceUsd,
|
|
123
|
+
valueUsd: position.valueUsd
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
log.info(tag, `✅ Enriched ${positions.length} positions with pricing data`);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
log.error(tag, 'Error enriching positions with pricing:', error);
|
|
130
|
+
// Don't throw - positions are still valid without pricing
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* No legacy cache format for staking (new feature)
|
|
135
|
+
*/
|
|
136
|
+
async getLegacyCached(params) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Get staking positions for a specific network and address
|
|
141
|
+
* Convenience method that wraps base get()
|
|
142
|
+
*/
|
|
143
|
+
async getStakingPositions(networkId, address, waitForFresh) {
|
|
144
|
+
const result = await this.get({ networkId, address }, waitForFresh);
|
|
145
|
+
return result.value || this.config.defaultValue;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get staking positions for multiple addresses (batch operation)
|
|
149
|
+
* OPTIMIZED: Uses Redis MGET for single round-trip
|
|
150
|
+
*/
|
|
151
|
+
async getBatchStakingPositions(items, waitForFresh) {
|
|
152
|
+
const tag = this.TAG + 'getBatchStakingPositions | ';
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
try {
|
|
155
|
+
// If waitForFresh=true, skip cache and fetch fresh data
|
|
156
|
+
if (waitForFresh) {
|
|
157
|
+
log.info(tag, `FORCE REFRESH: Bypassing cache for ${items.length} addresses`);
|
|
158
|
+
const fetchStart = Date.now();
|
|
159
|
+
const results = new Map();
|
|
160
|
+
const fetchPromises = items.map(async (item) => {
|
|
161
|
+
try {
|
|
162
|
+
const freshData = await this.fetchFresh({ networkId: item.networkId, address: item.address });
|
|
163
|
+
const key = `${item.networkId}:${item.address}`;
|
|
164
|
+
results.set(key, freshData);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
log.error(tag, `Failed to fetch fresh ${item.networkId}/${item.address}:`, error);
|
|
168
|
+
const key = `${item.networkId}:${item.address}`;
|
|
169
|
+
results.set(key, []);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
await Promise.all(fetchPromises);
|
|
173
|
+
log.info(tag, `Force refresh completed: fetched ${items.length} addresses in ${Date.now() - fetchStart}ms`);
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
// Normal flow: Check cache first
|
|
177
|
+
log.info(tag, `Batch request for ${items.length} addresses using Redis MGET`);
|
|
178
|
+
// Build all Redis keys
|
|
179
|
+
const keys = items.map(item => this.buildKey({ networkId: item.networkId, address: item.address }));
|
|
180
|
+
// PERF: Use MGET to fetch all keys in ONE Redis round-trip
|
|
181
|
+
const cachedValues = await this.redis.mget(...keys);
|
|
182
|
+
// Process results
|
|
183
|
+
const results = new Map();
|
|
184
|
+
const missedItems = [];
|
|
185
|
+
for (let i = 0; i < items.length; i++) {
|
|
186
|
+
const item = items[i];
|
|
187
|
+
const cached = cachedValues[i];
|
|
188
|
+
const itemKey = `${item.networkId}:${item.address}`;
|
|
189
|
+
if (cached) {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(cached);
|
|
192
|
+
if (parsed.value && Array.isArray(parsed.value)) {
|
|
193
|
+
results.set(itemKey, parsed.value);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Cache miss - record for fetching
|
|
202
|
+
missedItems.push({ ...item, index: i });
|
|
203
|
+
results.set(itemKey, []); // Placeholder
|
|
204
|
+
}
|
|
205
|
+
const responseTime = Date.now() - startTime;
|
|
206
|
+
const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
|
|
207
|
+
log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
|
|
208
|
+
// If we have cache misses, trigger background refresh (non-blocking)
|
|
209
|
+
if (missedItems.length > 0) {
|
|
210
|
+
log.info(tag, `Triggering background refresh for ${missedItems.length} cache misses`);
|
|
211
|
+
missedItems.forEach(item => {
|
|
212
|
+
this.triggerAsyncRefresh({ networkId: item.networkId, address: item.address }, 'high');
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
log.error(tag, 'Error in batch staking request:', error);
|
|
219
|
+
// Return empty arrays for all items
|
|
220
|
+
const results = new Map();
|
|
221
|
+
items.forEach(item => {
|
|
222
|
+
const key = `${item.networkId}:${item.address}`;
|
|
223
|
+
results.set(key, []);
|
|
224
|
+
});
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
exports.StakingCache = StakingCache;
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { BalanceCache } from '../stores/balance-cache';
|
|
|
9
9
|
import { PriceCache } from '../stores/price-cache';
|
|
10
10
|
import { PortfolioCache } from '../stores/portfolio-cache';
|
|
11
11
|
import { TransactionCache } from '../stores/transaction-cache';
|
|
12
|
+
import { StakingCache } from '../stores/staking-cache';
|
|
12
13
|
import { RefreshWorker, startUnifiedWorker } from '../workers/refresh-worker';
|
|
13
14
|
import type { BaseCache } from './base-cache';
|
|
14
15
|
import type { HealthCheckResult } from '../types';
|
|
@@ -24,10 +25,12 @@ export interface CacheManagerConfig {
|
|
|
24
25
|
redisQueue?: any; // Dedicated Redis client for blocking queue operations (brpop, etc.)
|
|
25
26
|
balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
|
|
26
27
|
markets?: any; // Optional: if not provided, price cache won't be initialized
|
|
28
|
+
networkModules?: Map<string, any>; // Optional: network modules for staking cache (keyed by networkId)
|
|
27
29
|
enableBalanceCache?: boolean;
|
|
28
30
|
enablePriceCache?: boolean;
|
|
29
31
|
enablePortfolioCache?: boolean;
|
|
30
32
|
enableTransactionCache?: boolean;
|
|
33
|
+
enableStakingCache?: boolean;
|
|
31
34
|
startWorkers?: boolean; // Auto-start workers on initialization
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -41,6 +44,7 @@ export class CacheManager {
|
|
|
41
44
|
private priceCache?: PriceCache;
|
|
42
45
|
private portfolioCache?: PortfolioCache;
|
|
43
46
|
private transactionCache?: TransactionCache;
|
|
47
|
+
private stakingCache?: StakingCache;
|
|
44
48
|
private workers: RefreshWorker[] = [];
|
|
45
49
|
|
|
46
50
|
constructor(config: CacheManagerConfig) {
|
|
@@ -71,6 +75,12 @@ export class CacheManager {
|
|
|
71
75
|
log.info(TAG, '✅ Transaction cache initialized');
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
// Initialize Staking Cache (with markets module for price enrichment)
|
|
79
|
+
if (config.enableStakingCache !== false && config.networkModules && config.networkModules.size > 0) {
|
|
80
|
+
this.stakingCache = new StakingCache(this.redis, config.networkModules, config.markets);
|
|
81
|
+
log.info(TAG, '✅ Staking cache initialized');
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
// Auto-start workers if requested
|
|
75
85
|
if (config.startWorkers) {
|
|
76
86
|
setImmediate(() => {
|
|
@@ -105,6 +115,10 @@ export class CacheManager {
|
|
|
105
115
|
cacheRegistry.set('portfolio', this.portfolioCache);
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
if (this.stakingCache) {
|
|
119
|
+
cacheRegistry.set('staking', this.stakingCache);
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
// Start unified worker if we have any caches with queues
|
|
109
123
|
if (cacheRegistry.size > 0) {
|
|
110
124
|
const worker = await startUnifiedWorker(
|
|
@@ -207,6 +221,18 @@ export class CacheManager {
|
|
|
207
221
|
};
|
|
208
222
|
}
|
|
209
223
|
|
|
224
|
+
// Check staking cache
|
|
225
|
+
if (this.stakingCache) {
|
|
226
|
+
const stakingHealth = await this.stakingCache.getHealth(forceRefresh);
|
|
227
|
+
checks.staking = stakingHealth;
|
|
228
|
+
|
|
229
|
+
if (stakingHealth.status === 'unhealthy') {
|
|
230
|
+
issues.push(...stakingHealth.issues.map(i => `Staking: ${i}`));
|
|
231
|
+
} else if (stakingHealth.status === 'degraded') {
|
|
232
|
+
warnings.push(...stakingHealth.warnings.map(w => `Staking: ${w}`));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
210
236
|
// Check workers
|
|
211
237
|
for (const worker of this.workers) {
|
|
212
238
|
const workerStats = await worker.getStats();
|
|
@@ -270,14 +296,15 @@ export class CacheManager {
|
|
|
270
296
|
balance: this.balanceCache,
|
|
271
297
|
price: this.priceCache,
|
|
272
298
|
portfolio: this.portfolioCache,
|
|
273
|
-
transaction: this.transactionCache
|
|
299
|
+
transaction: this.transactionCache,
|
|
300
|
+
staking: this.stakingCache
|
|
274
301
|
};
|
|
275
302
|
}
|
|
276
303
|
|
|
277
304
|
/**
|
|
278
305
|
* Get specific cache by name
|
|
279
306
|
*/
|
|
280
|
-
getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction') {
|
|
307
|
+
getCache(name: 'balance' | 'price' | 'portfolio' | 'transaction' | 'staking') {
|
|
281
308
|
switch (name) {
|
|
282
309
|
case 'balance':
|
|
283
310
|
return this.balanceCache;
|
|
@@ -287,6 +314,8 @@ export class CacheManager {
|
|
|
287
314
|
return this.portfolioCache;
|
|
288
315
|
case 'transaction':
|
|
289
316
|
return this.transactionCache;
|
|
317
|
+
case 'staking':
|
|
318
|
+
return this.stakingCache;
|
|
290
319
|
default:
|
|
291
320
|
return undefined;
|
|
292
321
|
}
|
|
@@ -295,7 +324,7 @@ export class CacheManager {
|
|
|
295
324
|
/**
|
|
296
325
|
* Clear all caches (use with caution!)
|
|
297
326
|
*/
|
|
298
|
-
async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number }> {
|
|
327
|
+
async clearAll(): Promise<{ balance?: number; price?: number; portfolio?: number; transaction?: number; staking?: number }> {
|
|
299
328
|
const tag = TAG + 'clearAll | ';
|
|
300
329
|
|
|
301
330
|
try {
|
|
@@ -317,6 +346,10 @@ export class CacheManager {
|
|
|
317
346
|
result.transaction = await this.transactionCache.clearAll();
|
|
318
347
|
}
|
|
319
348
|
|
|
349
|
+
if (this.stakingCache) {
|
|
350
|
+
result.staking = await this.stakingCache.clearAll();
|
|
351
|
+
}
|
|
352
|
+
|
|
320
353
|
log.info(tag, 'Cleared all caches:', result);
|
|
321
354
|
return result;
|
|
322
355
|
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { BalanceCache } from './stores/balance-cache';
|
|
|
14
14
|
export { PriceCache } from './stores/price-cache';
|
|
15
15
|
export { PortfolioCache } from './stores/portfolio-cache';
|
|
16
16
|
export { TransactionCache } from './stores/transaction-cache';
|
|
17
|
+
export { StakingCache } from './stores/staking-cache';
|
|
17
18
|
|
|
18
19
|
// Worker exports
|
|
19
20
|
export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
|
|
@@ -32,6 +33,7 @@ export type {
|
|
|
32
33
|
export type { BalanceData } from './stores/balance-cache';
|
|
33
34
|
export type { PriceData } from './stores/price-cache';
|
|
34
35
|
export type { PortfolioData, ChartData } from './stores/portfolio-cache';
|
|
36
|
+
export type { StakingPosition } from './stores/staking-cache';
|
|
35
37
|
|
|
36
38
|
// Config type export
|
|
37
39
|
export type { CacheManagerConfig } from './core/cache-manager';
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/*
|
|
2
|
+
StakingCache - Staking position cache implementation
|
|
3
|
+
|
|
4
|
+
Extends BaseCache with staking-specific logic for Cosmos-based chains.
|
|
5
|
+
Caches delegation, reward, and unbonding positions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BaseCache } from '../core/base-cache';
|
|
9
|
+
import type { CacheConfig } from '../types';
|
|
10
|
+
|
|
11
|
+
const log = require('@pioneer-platform/loggerdog')();
|
|
12
|
+
const TAG = ' | StakingCache | ';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Staking position data structure
|
|
16
|
+
*/
|
|
17
|
+
export interface StakingPosition {
|
|
18
|
+
type: 'delegation' | 'reward' | 'unbonding';
|
|
19
|
+
chart: 'staking';
|
|
20
|
+
context: string;
|
|
21
|
+
contextType: string;
|
|
22
|
+
caip: string;
|
|
23
|
+
networkId: string;
|
|
24
|
+
pubkey: string;
|
|
25
|
+
validatorAddress?: string;
|
|
26
|
+
validator?: string;
|
|
27
|
+
balance: number;
|
|
28
|
+
denom: string;
|
|
29
|
+
ticker: string;
|
|
30
|
+
symbol: string;
|
|
31
|
+
name: string;
|
|
32
|
+
icon: string;
|
|
33
|
+
status: 'active' | 'claimable' | 'unbonding';
|
|
34
|
+
completionTime?: string; // For unbonding
|
|
35
|
+
shares?: string; // For delegations
|
|
36
|
+
priceUsd?: number;
|
|
37
|
+
valueUsd?: number;
|
|
38
|
+
updated: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Network module interface for staking
|
|
43
|
+
*/
|
|
44
|
+
interface NetworkModule {
|
|
45
|
+
getStakingPositions(address: string): Promise<StakingPosition[]>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* StakingCache - Caches Cosmos staking positions (delegations, rewards, unbonding)
|
|
50
|
+
*/
|
|
51
|
+
export class StakingCache extends BaseCache<StakingPosition[]> {
|
|
52
|
+
private networkModules: Map<string, NetworkModule>;
|
|
53
|
+
private markets: any;
|
|
54
|
+
|
|
55
|
+
constructor(redis: any, networkModules: Map<string, NetworkModule>, markets?: any, config?: Partial<CacheConfig>) {
|
|
56
|
+
const defaultConfig: CacheConfig = {
|
|
57
|
+
name: 'staking',
|
|
58
|
+
keyPrefix: 'staking_v1:',
|
|
59
|
+
ttl: 5 * 60 * 1000, // 5 minutes - staking changes slowly
|
|
60
|
+
staleThreshold: 2 * 60 * 1000, // Refresh after 2 minutes
|
|
61
|
+
enableTTL: true, // Enable expiration
|
|
62
|
+
queueName: 'cache-refresh',
|
|
63
|
+
enableQueue: true,
|
|
64
|
+
maxRetries: 3,
|
|
65
|
+
retryDelay: 10000,
|
|
66
|
+
blockOnMiss: false, // Return [] immediately, fetch async
|
|
67
|
+
enableLegacyFallback: false, // No legacy format
|
|
68
|
+
defaultValue: [], // Empty array for no positions
|
|
69
|
+
maxConcurrentJobs: 10,
|
|
70
|
+
apiTimeout: 30000, // 30s timeout for blockchain API
|
|
71
|
+
logCacheHits: false,
|
|
72
|
+
logCacheMisses: true,
|
|
73
|
+
logRefreshJobs: true
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
super(redis, { ...defaultConfig, ...config });
|
|
77
|
+
this.networkModules = networkModules;
|
|
78
|
+
this.markets = markets;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build Redis key for staking data
|
|
83
|
+
* Format: staking_v1:networkId:address
|
|
84
|
+
*/
|
|
85
|
+
protected buildKey(params: Record<string, any>): string {
|
|
86
|
+
const { networkId, address } = params;
|
|
87
|
+
if (!networkId || !address) {
|
|
88
|
+
throw new Error('StakingCache.buildKey: networkId and address required');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const normalizedNetworkId = networkId.toLowerCase();
|
|
92
|
+
const normalizedAddress = address.toLowerCase();
|
|
93
|
+
|
|
94
|
+
return `${this.config.keyPrefix}${normalizedNetworkId}:${normalizedAddress}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fetch staking positions from blockchain via network module
|
|
99
|
+
* and enrich with pricing data from markets module
|
|
100
|
+
*/
|
|
101
|
+
protected async fetchFromSource(params: Record<string, any>): Promise<StakingPosition[]> {
|
|
102
|
+
const tag = this.TAG + 'fetchFromSource | ';
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const { networkId, address } = params;
|
|
106
|
+
|
|
107
|
+
log.debug(tag, `Fetching staking positions for ${address} on ${networkId}`);
|
|
108
|
+
|
|
109
|
+
// Get the appropriate network module
|
|
110
|
+
const networkModule = this.networkModules.get(networkId);
|
|
111
|
+
if (!networkModule) {
|
|
112
|
+
log.warn(tag, `No network module found for ${networkId}`);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fetch staking positions from network module (raw blockchain data)
|
|
117
|
+
const positions = await networkModule.getStakingPositions(address);
|
|
118
|
+
|
|
119
|
+
if (!positions || !Array.isArray(positions)) {
|
|
120
|
+
log.warn(tag, `Invalid positions returned for ${networkId}/${address}`);
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log.info(tag, `Found ${positions.length} staking positions for ${networkId}/${address}`);
|
|
125
|
+
|
|
126
|
+
// Enrich with pricing data if markets module is available
|
|
127
|
+
if (this.markets && positions.length > 0) {
|
|
128
|
+
await this.enrichPositionsWithPricing(positions);
|
|
129
|
+
} else if (!this.markets) {
|
|
130
|
+
log.warn(tag, 'Markets module not available, positions will have no pricing');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return positions;
|
|
134
|
+
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log.error(tag, 'Error fetching staking positions:', error);
|
|
137
|
+
// Return empty array instead of throwing - staking is optional
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Enrich staking positions with USD pricing
|
|
144
|
+
* Uses the markets module to fetch prices for the native tokens
|
|
145
|
+
*/
|
|
146
|
+
private async enrichPositionsWithPricing(positions: StakingPosition[]): Promise<void> {
|
|
147
|
+
const tag = TAG + 'enrichPositionsWithPricing | ';
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Collect unique CAIPs from all positions
|
|
151
|
+
const uniqueCAIPs = [...new Set(positions.map(p => p.caip))];
|
|
152
|
+
log.debug(tag, `Fetching prices for ${uniqueCAIPs.length} unique assets:`, uniqueCAIPs);
|
|
153
|
+
|
|
154
|
+
// Batch fetch prices from markets module
|
|
155
|
+
const prices: Record<string, number> = {};
|
|
156
|
+
|
|
157
|
+
for (const caip of uniqueCAIPs) {
|
|
158
|
+
try {
|
|
159
|
+
const price = await this.markets.getAssetPriceByCaip(caip);
|
|
160
|
+
prices[caip] = price || 0;
|
|
161
|
+
log.debug(tag, `Price for ${caip}: $${price}`);
|
|
162
|
+
} catch (priceError) {
|
|
163
|
+
log.error(tag, `Error fetching price for ${caip}:`, priceError);
|
|
164
|
+
prices[caip] = 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Enrich each position with its price
|
|
169
|
+
for (const position of positions) {
|
|
170
|
+
const priceUsd = prices[position.caip] || 0;
|
|
171
|
+
position.priceUsd = priceUsd;
|
|
172
|
+
position.valueUsd = position.balance * priceUsd;
|
|
173
|
+
|
|
174
|
+
log.debug(tag, `Enriched ${position.type} position:`, {
|
|
175
|
+
caip: position.caip,
|
|
176
|
+
balance: position.balance,
|
|
177
|
+
priceUsd,
|
|
178
|
+
valueUsd: position.valueUsd
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log.info(tag, `✅ Enriched ${positions.length} positions with pricing data`);
|
|
183
|
+
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log.error(tag, 'Error enriching positions with pricing:', error);
|
|
186
|
+
// Don't throw - positions are still valid without pricing
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* No legacy cache format for staking (new feature)
|
|
192
|
+
*/
|
|
193
|
+
protected async getLegacyCached(params: Record<string, any>): Promise<StakingPosition[] | null> {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get staking positions for a specific network and address
|
|
199
|
+
* Convenience method that wraps base get()
|
|
200
|
+
*/
|
|
201
|
+
async getStakingPositions(networkId: string, address: string, waitForFresh?: boolean): Promise<StakingPosition[]> {
|
|
202
|
+
const result = await this.get({ networkId, address }, waitForFresh);
|
|
203
|
+
return result.value || this.config.defaultValue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get staking positions for multiple addresses (batch operation)
|
|
208
|
+
* OPTIMIZED: Uses Redis MGET for single round-trip
|
|
209
|
+
*/
|
|
210
|
+
async getBatchStakingPositions(
|
|
211
|
+
items: Array<{ networkId: string; address: string }>,
|
|
212
|
+
waitForFresh?: boolean
|
|
213
|
+
): Promise<Map<string, StakingPosition[]>> {
|
|
214
|
+
const tag = this.TAG + 'getBatchStakingPositions | ';
|
|
215
|
+
const startTime = Date.now();
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
// If waitForFresh=true, skip cache and fetch fresh data
|
|
219
|
+
if (waitForFresh) {
|
|
220
|
+
log.info(tag, `FORCE REFRESH: Bypassing cache for ${items.length} addresses`);
|
|
221
|
+
const fetchStart = Date.now();
|
|
222
|
+
|
|
223
|
+
const results = new Map<string, StakingPosition[]>();
|
|
224
|
+
const fetchPromises = items.map(async (item) => {
|
|
225
|
+
try {
|
|
226
|
+
const freshData = await this.fetchFresh({ networkId: item.networkId, address: item.address });
|
|
227
|
+
const key = `${item.networkId}:${item.address}`;
|
|
228
|
+
results.set(key, freshData);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
log.error(tag, `Failed to fetch fresh ${item.networkId}/${item.address}:`, error);
|
|
231
|
+
const key = `${item.networkId}:${item.address}`;
|
|
232
|
+
results.set(key, []);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await Promise.all(fetchPromises);
|
|
237
|
+
log.info(tag, `Force refresh completed: fetched ${items.length} addresses in ${Date.now() - fetchStart}ms`);
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Normal flow: Check cache first
|
|
242
|
+
log.info(tag, `Batch request for ${items.length} addresses using Redis MGET`);
|
|
243
|
+
|
|
244
|
+
// Build all Redis keys
|
|
245
|
+
const keys = items.map(item => this.buildKey({ networkId: item.networkId, address: item.address }));
|
|
246
|
+
|
|
247
|
+
// PERF: Use MGET to fetch all keys in ONE Redis round-trip
|
|
248
|
+
const cachedValues = await this.redis.mget(...keys);
|
|
249
|
+
|
|
250
|
+
// Process results
|
|
251
|
+
const results = new Map<string, StakingPosition[]>();
|
|
252
|
+
const missedItems: Array<{ networkId: string; address: string; index: number }> = [];
|
|
253
|
+
|
|
254
|
+
for (let i = 0; i < items.length; i++) {
|
|
255
|
+
const item = items[i];
|
|
256
|
+
const cached = cachedValues[i];
|
|
257
|
+
const itemKey = `${item.networkId}:${item.address}`;
|
|
258
|
+
|
|
259
|
+
if (cached) {
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(cached);
|
|
262
|
+
if (parsed.value && Array.isArray(parsed.value)) {
|
|
263
|
+
results.set(itemKey, parsed.value);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
log.warn(tag, `Failed to parse cached value for ${keys[i]}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Cache miss - record for fetching
|
|
272
|
+
missedItems.push({ ...item, index: i });
|
|
273
|
+
results.set(itemKey, []); // Placeholder
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const responseTime = Date.now() - startTime;
|
|
277
|
+
const hitRate = ((items.length - missedItems.length) / items.length * 100).toFixed(1);
|
|
278
|
+
log.info(tag, `MGET completed: ${items.length} keys in ${responseTime}ms (${hitRate}% hit rate)`);
|
|
279
|
+
|
|
280
|
+
// If we have cache misses, trigger background refresh (non-blocking)
|
|
281
|
+
if (missedItems.length > 0) {
|
|
282
|
+
log.info(tag, `Triggering background refresh for ${missedItems.length} cache misses`);
|
|
283
|
+
|
|
284
|
+
missedItems.forEach(item => {
|
|
285
|
+
this.triggerAsyncRefresh({ networkId: item.networkId, address: item.address }, 'high');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return results;
|
|
290
|
+
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log.error(tag, 'Error in batch staking request:', error);
|
|
293
|
+
// Return empty arrays for all items
|
|
294
|
+
const results = new Map<string, StakingPosition[]>();
|
|
295
|
+
items.forEach(item => {
|
|
296
|
+
const key = `${item.networkId}:${item.address}`;
|
|
297
|
+
results.set(key, []);
|
|
298
|
+
});
|
|
299
|
+
return results;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|