UNPKG

chronik-cache

Version:

A cache helper for chronik-client

1,107 lines (976 loc) 53.5 kB
const DbUtils = require('./lib/dbUtils'); const WebSocketManager = require('./lib/WebSocketManager'); const Logger = require('./lib/Logger'); const { encodeCashAddress } = require('ecashaddrjs'); const { CACHE_STATUS, DEFAULT_CONFIG } = require('./constants'); const FailoverHandler = require('./lib/failover'); const MAX_ITEMS_PER_KEY = DEFAULT_CONFIG.MAX_ITEMS_PER_KEY; const { computeHash } = require('./lib/hash'); const TaskQueue = require('./lib/TaskQueue'); // Import external sortTxIds function from lib const sortTxIds = require('./lib/sortTxIds'); const CacheStats = require('./lib/CacheStats'); class ChronikCache { constructor(chronik, { maxTxLimit = DEFAULT_CONFIG.MAX_TX_LIMIT, maxCacheSize = DEFAULT_CONFIG.MAX_CACHE_SIZE, failoverOptions = {}, enableLogging = true, enableTimer = false, wsTimeout = DEFAULT_CONFIG.WS_TIMEOUT, wsExtendTimeout = DEFAULT_CONFIG.WS_EXTEND_TIMEOUT } = {}) { this.chronik = chronik; this.maxTxLimit = maxTxLimit; this.defaultPageSize = DEFAULT_CONFIG.DEFAULT_PAGE_SIZE; this.cacheDir = DEFAULT_CONFIG.CACHE_DIR; this.maxCacheSize = maxCacheSize * 1024 * 1024; this.enableLogging = enableLogging; this.logger = new Logger(enableLogging, enableTimer); // Initialize database utilities this.db = new DbUtils(this.cacheDir, { valueEncoding: 'json', maxCacheSize: this.maxCacheSize, enableLogging }); this.statusMap = new Map(); // Pass onEvict callback to update cache status to UNKNOWN this.wsManager = new WebSocketManager(chronik, failoverOptions, enableLogging, { wsTimeout, wsExtendTimeout, onEvict: (identifier, subscriptionType) => { const isToken = subscriptionType === 'token'; this._setCacheStatus(identifier, CACHE_STATUS.UNKNOWN, isToken); } }); this.updateLocks = new Map(); // Add script type to address cache mapping this.scriptToAddressMap = new Map(); // Add failover handler this.failover = new FailoverHandler(failoverOptions); // 添加token缓存相关的Map this.tokenStatusMap = new Map(); this.tokenUpdateLocks = new Map(); // 初始化全局元数据缓存,防止频繁访问数据库 this.globalMetadataCache = new Map(); // Set LRU cache limit for global metadata cache this.globalMetadataCacheLimit = DEFAULT_CONFIG.GLOBAL_METADATA_CACHE_LIMIT || 100; // 初始化全局任务队列,最大并发 2 个 this.updateQueue = new TaskQueue(2); // 初始化交易更新队列,最大并发 5 个 this.txUpdateQueue = new TaskQueue(5); // ========================================================= // NEW: In-memory cache to hold the entire persistent cache this.addressMemoryCache = new Map(); this.tokenMemoryCache = new Map(); // ========================================================= // Initialize stats this.stats = new CacheStats(this, this.logger); // 在内存缓存中,为每个条目都维护一个独立的过期时间(初始120秒)。 // 每访问一次该条目,就将过期时间增加10秒。 // 后台定时检查过期条目,将其移除。 this._startMemoryCacheExpirationCheckTimer(); // 添加防抖计时器Map this.debounceTimers = new Map(); return new Proxy(this, { get: (target, prop) => { // If the property exists on ChronikCache object, return directly if (prop in target) { return target[prop]; } // If the underlying chronik has the function, wrap it to add status: 3 to the result if (typeof chronik[prop] === 'function') { return (...args) => { this.logger.log(`Forwarding uncached method call: ${prop}`); return Promise.resolve(chronik[prop](...args)).then(result => { if (typeof result === 'object' && result !== null) { return { ...result, status: 3 }; } return result; }); }; } // If the underlying chronik has this property, return it if (prop in chronik) { return chronik[prop]; } return undefined; } }); } // 添加防抖工具方法 _debounce(key, fn, delay = 500) { if (this.debounceTimers.has(key)) { clearTimeout(this.debounceTimers.get(key)); } const timer = setTimeout(async () => { this.debounceTimers.delete(key); try { await fn(); } catch (error) { this.logger.error(`Error in debounced function for ${key}:`, error); } }, delay); this.debounceTimers.set(key, timer); } // Read cache from database by reading txMap and txOrder separately async _readCache(addressOrTokenId) { try { let txOrder, txMap; // Check for paginated txOrder let txOrderMeta = await this.db.get(`${addressOrTokenId}:txOrder:meta`); if (txOrderMeta) { const { pageCount } = txOrderMeta; txOrder = []; for (let i = 0; i < pageCount; i++) { let chunk = await this.db.get(`${addressOrTokenId}:txOrder:${i}`); txOrder = txOrder.concat(chunk); } } else { txOrder = await this.db.get(`${addressOrTokenId}:txOrder`); } if (!txOrder) return null; // Check for paginated txMap let txMapMeta = await this.db.get(`${addressOrTokenId}:txMap:meta`); if (txMapMeta) { const { pageCount } = txMapMeta; txMap = {}; for (let i = 0; i < pageCount; i++) { let chunk = await this.db.get(`${addressOrTokenId}:txMap:${i}`); txMap = Object.assign(txMap, chunk); } } else { txMap = await this.db.get(`${addressOrTokenId}:txMap`); } // Update global metadata const now = Date.now(); let metadata = await this._getGlobalMetadata(addressOrTokenId); if (!metadata) { metadata = { accessCount: 0, createdAt: now }; } metadata.accessCount = (metadata.accessCount || 0) + 1; metadata.lastAccessAt = now; this._updateGlobalMetadata(addressOrTokenId, metadata); return { txMap, txOrder, numTxs: (metadata.numTxs !== undefined ? metadata.numTxs : txOrder.length) }; } catch (error) { return null; } } // Write cache into database with pagination support if tx count exceeds MAX_ITEMS_PER_KEY async _writeCache(addressOrTokenId, data, isToken = false) { // Use computeHash to generate hash value const newHash = computeHash(data.txOrder); // Try to get the existing hash from global metadata let metadata = await this._getGlobalMetadata(addressOrTokenId, isToken); // Log the change of hash values if (metadata && metadata.dataHash) { this.logger.log(`[${addressOrTokenId}] Changing hash from ${metadata.dataHash} to ${newHash}`); } else { this.logger.log(`[${addressOrTokenId}] No previous hash found. Setting new hash: ${newHash}`); } // If there is no difference, skip the DB update if (metadata && metadata.dataHash && metadata.dataHash === newHash) { this.logger.log(`No change detected for ${isToken ? 'token' : 'address'}: ${addressOrTokenId}, skipping DB update.`); return; } const totalTxs = data.txOrder.length; if (totalTxs > MAX_ITEMS_PER_KEY) { const pageCount = Math.ceil(totalTxs / MAX_ITEMS_PER_KEY); for (let i = 0; i < pageCount; i++) { let chunk = data.txOrder.slice(i * MAX_ITEMS_PER_KEY, (i + 1) * MAX_ITEMS_PER_KEY); await this.db.put(`${addressOrTokenId}:txOrder:${i}`, chunk); } await this.db.put(`${addressOrTokenId}:txOrder:meta`, { pageCount, totalTxs }); for (let i = 0; i < pageCount; i++) { let chunkKeys = data.txOrder.slice(i * MAX_ITEMS_PER_KEY, (i + 1) * MAX_ITEMS_PER_KEY); let chunkMap = {}; for (const txid of chunkKeys) { chunkMap[txid] = data.txMap[txid]; } await this.db.put(`${addressOrTokenId}:txMap:${i}`, chunkMap); } await this.db.put(`${addressOrTokenId}:txMap:meta`, { pageCount, totalTxs }); } else { await this.db.put(`${addressOrTokenId}:txMap`, data.txMap); await this.db.put(`${addressOrTokenId}:txOrder`, data.txOrder); } // Update global metadata with the new hash value metadata = metadata || { accessCount: 0, createdAt: Date.now() }; metadata.dataHash = newHash; metadata.numTxs = totalTxs; metadata.updatedAt = Date.now(); await this._updateGlobalMetadata(addressOrTokenId, metadata, isToken); this.logger.log(`Cache written for ${isToken ? 'token' : 'address'}: ${addressOrTokenId}`); } // 更新全局元数据 async _updateGlobalMetadata(identifier, metadata, isToken = false) { const key = isToken ? `token:${identifier}` : `address:${identifier}`; await this.db.updateGlobalMetadata(key, metadata); // Update in-memory metadata cache with LRU ordering if (this.globalMetadataCache.has(key)) { this.globalMetadataCache.delete(key); } this.globalMetadataCache.set(key, metadata); this._maintainGlobalMetadataCacheLimit(); } // 获取全局元数据 async _getGlobalMetadata(identifier, isToken = false) { const key = isToken ? `token:${identifier}` : `address:${identifier}`; // Try to get from in-memory cache if (this.globalMetadataCache.has(key)) { // Update recency ordering by re-inserting the key const metadata = this.globalMetadataCache.get(key); this.globalMetadataCache.delete(key); this.globalMetadataCache.set(key, metadata); return metadata; } // Get from database and cache it if found const metadata = await this.db.getGlobalMetadata(key); if (metadata) { this.globalMetadataCache.set(key, metadata); this._maintainGlobalMetadataCacheLimit(); } return metadata; } /* --------------------- 初始化 WebSocket --------------------- */ async _initWebsocketForAddress(address) { try { return await this.failover.handleWebSocketOperation(async () => { this.wsManager.resetWsTimer(address, { isToken: false }, (addr) => { this._setCacheStatus(addr, CACHE_STATUS.UNKNOWN); this._resetMemoryCache(addr, false); }); await this.wsManager.initWebsocketForAddress(address, async (addr, txid, msgType) => { const key = `${addr}:${msgType}`; if (msgType === 'TX_ADDED_TO_MEMPOOL') { this._debounce(key, async () => { const apiNumTxs = await this._quickGetTxCount(addr, 'address'); this._resetMemoryCache(addr, false); await this._updateCache(addr, apiNumTxs, this.defaultPageSize); }); } else if (msgType === 'TX_FINALIZED') { this._debounce(key, async () => { this._resetMemoryCache(addr, false); await this._updateUnconfirmedTx(addr, txid); }); } }); }, address, 'WebSocket initialization'); } catch (error) { this.logger.error('Error in websocket initialization, falling back to UNKNOWN status', error); this._setCacheStatus(address, CACHE_STATUS.UNKNOWN); } } async _initWebsocketForToken(tokenId) { try { return await this.failover.handleWebSocketOperation(async () => { this.wsManager.resetWsTimer(tokenId, { isToken: true }, (id) => { this._setCacheStatus(id, CACHE_STATUS.UNKNOWN, true); this._resetMemoryCache(id, true); }); await this.wsManager.initWebsocketForToken(tokenId, async (id, txid, msgType) => { const key = `${id}:${msgType}`; if (msgType === 'TX_ADDED_TO_MEMPOOL') { this._debounce(key, async () => { const apiNumTxs = await this._quickGetTxCount(id, 'token'); this._resetMemoryCache(id, true); await this._updateTokenCache(id, apiNumTxs, this.defaultPageSize); }); } else if (msgType === 'TX_FINALIZED') { this._debounce(key, async () => { this._resetMemoryCache(id, true); await this._updateUnconfirmedTx(id, txid); }); } }); }, tokenId, 'WebSocket initialization'); } catch (error) { this.logger.error('Error in websocket initialization for token, falling back to UNKNOWN status', error); this._setCacheStatus(tokenId, CACHE_STATUS.UNKNOWN, true); } } /* --------------------- 缓存状态管理方法 --------------------- */ _getCacheStatus(identifier, isToken = false) { const isUpdating = this._isUpdating(identifier, isToken); if (isUpdating) { return CACHE_STATUS.UPDATING; } const statusMap = isToken ? this.tokenStatusMap : this.statusMap; const status = statusMap.get(identifier); return status?.status ?? CACHE_STATUS.UNKNOWN; } _setCacheStatus(identifier, status, isToken = false) { const now = Date.now(); const targetMap = isToken ? this.tokenStatusMap : this.statusMap; const existingStatus = targetMap.get(identifier); // 添加详细的状态变化日志 this.logger.log(`[${isToken ? 'Token' : 'Address'} ${identifier}] Attempting to change status from ${existingStatus?.status} to ${status} Current update lock: ${this._isUpdating(identifier, isToken)} `); targetMap.set(identifier, { status, cacheTimestamp: existingStatus?.cacheTimestamp || now }); } /* --------------------- Core Cache Update Logic --------------------- */ _isUpdating(identifier, isToken = false) { const updateMap = isToken ? this.tokenUpdateLocks : this.updateLocks; return updateMap.has(identifier); } // 修改检查限制的方法名和实现 _checkTxLimit(identifier, numTxs, isToken = false) { if (numTxs > this.maxTxLimit) { const idType = isToken ? 'Token' : 'Address'; this.logger.log(`[${idType} ${identifier}] Transaction count (${numTxs}) exceeds maxTxLimit (${this.maxTxLimit}), setting to REJECT status`); this._setCacheStatus(identifier, CACHE_STATUS.REJECT, isToken); return true; } return false; } async _checkAndUpdateCache(address, apiNumTxs, pageSize, forceUpdate = false) { if (this._checkTxLimit(address, apiNumTxs)) { return; } if (this._isUpdating(address)) { this.logger.log(`[${address}] Cache update already in progress, skipping`); return; } Promise.resolve().then(async () => { try { const cachedData = await this._readCache(address); let dynamicPageSize = pageSize; if (cachedData && typeof cachedData.numTxs === 'number') { dynamicPageSize = apiNumTxs - cachedData.numTxs; if (dynamicPageSize < 1) { dynamicPageSize = 1; } } if (dynamicPageSize > 200) { dynamicPageSize = 200; } if (!cachedData || cachedData.numTxs !== apiNumTxs || forceUpdate) { this.updateLocks.set(address, true); this.updateQueue.enqueue(async () => { try { this.logger.log(`[${address}] Cache needs update${forceUpdate ? ' (forced update)' : ''}, updating with dynamic page size: ${dynamicPageSize}`); await this._updateCache(address, apiNumTxs, dynamicPageSize); } finally { this.updateLocks.delete(address); } }); this.logger.log(`[${address}] Current global update queue length: ${this.updateQueue.getQueueLength()}`); } else { this.logger.log(`[${address}] Cache is up to date, setting status to LATEST`); this._setCacheStatus(address, CACHE_STATUS.LATEST); this._initWebsocketForAddress(address); } } catch (error) { this.logger.error('Cache update error:', error); this.logger.log(`[${address}] Error occurred, setting status to UNKNOWN`); this._setCacheStatus(address, CACHE_STATUS.UNKNOWN); } }); } async _updateCache(address, totalNumTxs, pageSize) { return await this.failover.executeWithRetry(async () => { try { if (this._checkTxLimit(address, totalNumTxs)) { return; } this.logger.log(`[${address}] Starting cache update.`); let currentPage = 0; let iterationCount = 0; let localCache = await this._readCache(address) || { txMap: {}, txOrder: [] }; let localTxMap = new Map(Object.entries(localCache.txMap)); let localTxOrder = localCache.txOrder; while (true) { const currentSize = localTxMap.size; this.logger.log(`[${address}] Updating cache page ${currentPage}, current size: ${currentSize}/${totalNumTxs}`); if (currentSize >= totalNumTxs) { // Final sorting of txOrder before final cache write using the helper // this.logger.startTimer(`[${address}] Final sorting tx order`); localTxOrder = sortTxIds(Array.from(localTxMap.keys()), key => localTxMap.get(key)); // this.logger.endTimer(`[${address}] Final sorting tx order`); // Always write the final state into the cache this.logger.startTimer(`[${address}] Final write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(address, updatedData); this.logger.endTimer(`[${address}] Final write cache`); this.logger.log(`[${address}] Cache update completed, final size: ${currentSize}`); break; } this.logger.startTimer(`[${address}] Fetch history`); const result = await this.chronik.address(address).history(currentPage, pageSize); this.logger.endTimer(`[${address}] Fetch history`); // this.logger.startTimer(`[${address}] Process tx`); result.txs.forEach(tx => { if (!localTxMap.has(tx.txid)) { localTxMap.set(tx.txid, tx); } }); // this.logger.endTimer(`[${address}] Process tx`); // this.logger.startTimer(`[${address}] Sorting tx order`); // Use helper function to sort txOrder localTxOrder = sortTxIds(Array.from(localTxMap.keys()), key => localTxMap.get(key)); // this.logger.endTimer(`[${address}] Sorting tx order`); if (localTxMap.size >= 2000) { if (iterationCount % 10 === 0) { this.logger.startTimer(`[${address}] Write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(address, updatedData); this.logger.endTimer(`[${address}] Write cache`); } else { this.logger.log(`[${address}] Skipping DB write to reduce overhead (iteration ${iterationCount}).`); } } else { this.logger.startTimer(`[${address}] Write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(address, updatedData); this.logger.endTimer(`[${address}] Write cache`); } currentPage++; iterationCount++; } // Clean up memory after cache update. // this.logger.log(`[${address}] Cleaning up memory used for cache update.`); localTxMap.clear(); localTxOrder = null; const currentStatus = this._getCacheStatus(address); if (currentStatus !== CACHE_STATUS.LATEST) { this.logger.log(`[${address}] Cache update complete, setting status to LATEST.`); this._setCacheStatus(address, CACHE_STATUS.LATEST); this._initWebsocketForAddress(address); } else { this.logger.log(`[${address}] Cache update complete, maintaining LATEST status.`); } } catch (error) { this.logger.error('[Cache] Error in _updateCache:', error); throw error; } }, `updateCache for ${address}`); } /* --------------------- Script Related Methods --------------------- */ // Convert script parameters to ecash address _convertScriptToAddress(type, hash) { try { // Ensure hash is lowercase hash = hash.toLowerCase(); // Use ecashaddrjs to convert script to ecash address const address = encodeCashAddress('ecash', type, hash); // Cache script to address mapping const scriptKey = `${type}:${hash}`; this.scriptToAddressMap.set(scriptKey, address); return address; } catch (error) { this.logger.error('Error converting script to address:', error); throw error; } } // Fluent interface for script method script(type, hash) { return { history: async (pageOffset = 0, pageSize = 200) => { // Convert script to address and use existing address query logic const address = this._convertScriptToAddress(type, hash); return await this.getAddressHistory(address, pageOffset, pageSize); } // Add other script-related methods here if needed }; } /* --------------------- External Interface Methods --------------------- */ // Clear cache entries for an address by deleting both keys async clearAddressCache(address) { await Promise.all([ this.db.deletePaginated(`${address}:txMap`), this.db.deletePaginated(`${address}:txOrder`) ]); this.wsManager.unsubscribeAddress(address); this._setCacheStatus(address, CACHE_STATUS.UNKNOWN); this.logger.log(`Cache cleared for address: ${address}`); } async clearAllCache() { try { // Clear all local cache data (addresses and tokens) await this.db.clear(); // Cancel all WebSocket subscriptions (addresses and tokens) this.wsManager.unsubscribeAll(); // Update cache status to UNKNOWN for each address this.statusMap.forEach((_, addr) => { this._setCacheStatus(addr, CACHE_STATUS.UNKNOWN); }); // Update token cache status to UNKNOWN for each token this.tokenStatusMap.forEach((_, tokenId) => { this._setCacheStatus(tokenId, CACHE_STATUS.UNKNOWN, true); }); this.logger.log('All cache cleared successfully'); } catch (error) { this.logger.error('Error clearing all cache:', error); } } async getAddressHistory(address, pageOffset = 0, pageSize = 200) { return await this.failover.executeWithRetry(async () => { try { const currentStatus = this._getCacheStatus(address); // If the cache is rejected, use chronik directly and add status: 2 if (currentStatus === CACHE_STATUS.REJECT) { const result = await this.chronik.address(address).history(pageOffset, Math.min(200, pageSize)); return { ...result, message: "Transaction count exceeds cache limit, serving directly from Chronik API", status: 2 }; } const apiPageSize = Math.min(200, pageSize); const cachePageSize = Math.min(4000, pageSize); const cachedData = await this._readCache(address); const metadata = await this._getGlobalMetadata(address); const cachedCount = metadata ? metadata.numTxs : (cachedData ? cachedData.numTxs : 0); this.logger.log(`[${address}] Cache status: ${currentStatus}, Cached txs: ${cachedCount}`); const wsTimeInfo = this.wsManager.getRemainingTime(address); if (wsTimeInfo.active) { // this.logger.log(`[${address}] WebSocket remaining time: ${wsTimeInfo.remainingSec} seconds`); } else { // this.logger.log(`[${address}] ${wsTimeInfo.message}`); if (currentStatus === CACHE_STATUS.LATEST) { this._initWebsocketForAddress(address); } } if (currentStatus === CACHE_STATUS.LATEST) { this.wsManager.resetWsTimer(address, { isToken: false }); } if (currentStatus !== CACHE_STATUS.LATEST) { // Call quick API to get the latest tx count const quickResult = await this._quickGetTxCount(address); const apiNumTxs = quickResult; this.logger.log(`[${address}] Quick API numTxs: ${apiNumTxs}`); if (currentStatus !== CACHE_STATUS.UPDATING) { this._checkAndUpdateCache(address, apiNumTxs, this.defaultPageSize); } // If user requests pageSize greater than 200, prompt with "cache is being prepared" (status: 1) if (pageSize > 200) { return { status: 1, message: "Cache is being prepared. Please wait for cache to be ready when requesting more than 200 transactions.", numTxs: 0, txs: [], numPages: 0 }; } // Fallback: use chronik API directly and attach status: 3 const apiResult = await this.chronik.address(address).history(pageOffset, apiPageSize); return { ...apiResult, status: 3 }; } const cachedResult = await this._getPageFromCache(address, pageOffset, cachePageSize); if (cachedResult) { return cachedResult; } const apiFallback = await this.chronik.address(address).history(pageOffset, apiPageSize); this.logger.log(`[${address}] API txs count (fallback): ${apiFallback.numTxs}`); return { ...apiFallback, status: 3 }; } catch (error) { this.logger.error('[Cache] Error in getAddressHistory:', error); throw error; } }, `getAddressHistory for ${address}`); } async _getPageFromCache(address, pageOffset, pageSize) { this.logger.startTimer(`[${address}] _getPageFromCache`); let cacheEntry = this.addressMemoryCache.get(address); const now = Date.now(); if (cacheEntry) { // Check if entry expired if (now > cacheEntry.expiry) { this.logger.log(`[${address}] In-memory cache entry expired`); this.addressMemoryCache.delete(address); cacheEntry = null; } else { // Use memory cache cacheEntry.expiry += 10 * 1000; this.logger.log(`[${address}] Use memory cache`); } } let cache; if (cacheEntry) { cache = cacheEntry.data; } else { this.logger.startTimer(`[${address}] Read persistent cache from DB`); cache = await this._readCache(address); this.logger.endTimer(`[${address}] Read DB cache`); if (!cache) return null; // Initial memory cache expiry time: 120 seconds this.addressMemoryCache.set(address, { data: cache, expiry: now + 120 * 1000 }); } const metadata = await this._getGlobalMetadata(address); if (!metadata) return null; // Ensure txOrder is sorted cache.txOrder = sortTxIds(cache.txOrder, key => cache.txMap[key]); // With 50% probability, compute hash and check consistency if (Math.random() < 0.5) { const newHash = computeHash(cache.txOrder); // Only output log when hash is changed if (newHash !== metadata.dataHash) { this.logger.log(`[${address}] newHash: ${newHash}, stored hash: ${metadata.dataHash}`); this.logger.log(`[${address}] Cache order hash mismatch detected. Triggering cache update and invalidating in-memory cache.`); this._checkAndUpdateCache(address, metadata.numTxs, this.defaultPageSize, true); // Invalidate the in-memory cache since data is stale this._resetMemoryCache(address, false); } } const start = pageOffset * pageSize; const end = start + pageSize; const txs = cache.txOrder.slice(start, end).map(txid => cache.txMap[txid]); await this._updatePageUnconfirmedTxs(address, cache, txs, false); this.logger.endTimer(`[${address}] _getPageFromCache`); return { txs, numPages: Math.ceil(metadata.numTxs / pageSize), numTxs: metadata.numTxs }; } address(address) { return { history: async (pageOffset = 0, pageSize = 200) => { return await this.getAddressHistory(address, pageOffset, pageSize); } // Add other methods here if needed }; } /* --------------------- Token Related Methods --------------------- */ async _checkAndUpdateTokenCache(tokenId, apiNumTxs, pageSize, forceUpdate = false) { if (this._checkTxLimit(tokenId, apiNumTxs, true)) { return; } if (this._isUpdating(tokenId, true)) { this.logger.log(`[Token ${tokenId}] Cache update already in progress, skipping`); return; } Promise.resolve().then(async () => { try { const cachedData = await this._readCache(tokenId); let dynamicPageSize = pageSize; if (cachedData && typeof cachedData.numTxs === 'number') { dynamicPageSize = apiNumTxs - cachedData.numTxs; if (dynamicPageSize < 1) { dynamicPageSize = 1; } } if (dynamicPageSize > 200) { dynamicPageSize = 200; } // 如果缓存不存在、交易数量不一致,或者强制更新(forceUpdate)为 true 时,则进行更新 if (!cachedData || cachedData.numTxs !== apiNumTxs || forceUpdate) { this.tokenUpdateLocks.set(tokenId, true); this.updateQueue.enqueue(async () => { try { this.logger.log(`[Token ${tokenId}] Cache needs update${forceUpdate ? ' (forced update)' : ''}, dynamic page size: ${dynamicPageSize}`); await this._updateTokenCache(tokenId, apiNumTxs, dynamicPageSize); } finally { this.tokenUpdateLocks.delete(tokenId); } }); this.logger.log(`[Token ${tokenId}] Current global update queue length: ${this.updateQueue.getQueueLength()}`); } else { this.logger.log(`[Token ${tokenId}] Cache is up to date, setting status to LATEST`); this._setCacheStatus(tokenId, CACHE_STATUS.LATEST, true); // 异步触发 WS 初始化,避免阻塞更新锁的释放 this._initWebsocketForToken(tokenId).catch(err => this.logger.error(err)); } } catch (error) { this.logger.error('Token cache update error:', error); this.logger.log(`[Token ${tokenId}] Error occurred, setting status to UNKNOWN`); this._setCacheStatus(tokenId, CACHE_STATUS.UNKNOWN, true); } }); } async _updateTokenCache(tokenId, totalNumTxs, pageSize) { return await this.failover.executeWithRetry(async () => { try { if (this._checkTxLimit(tokenId, totalNumTxs, true)) { return; } this.logger.log(`[${tokenId}] Starting cache update.`); let currentPage = 0; let iterationCount = 0; let localCache = await this._readCache(tokenId) || { txMap: {}, txOrder: [] }; let localTxMap = new Map(Object.entries(localCache.txMap)); let localTxOrder = localCache.txOrder; while (true) { const currentSize = localTxMap.size; this.logger.log(`[${tokenId}] Updating cache page ${currentPage}, current size: ${currentSize}/${totalNumTxs}`); if (currentSize >= totalNumTxs) { // Re-sort the txOrder before final cache write // this.logger.startTimer(`[${tokenId}] Final sorting tx order`); localTxOrder = sortTxIds(Array.from(localTxMap.keys()), key => localTxMap.get(key)); // this.logger.endTimer(`[${tokenId}] Final sorting tx order`); // Always write the final state into the cache this.logger.startTimer(`[${tokenId}] Final write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(tokenId, updatedData, true); this.logger.endTimer(`[${tokenId}] Final write cache`); this.logger.log(`[${tokenId}] Cache update completed, final size: ${currentSize}`); break; } this.logger.startTimer(`[${tokenId}] Fetch history`); const result = await this.chronik.tokenId(tokenId).history(currentPage, pageSize); this.logger.endTimer(`[${tokenId}] Fetch history`); // this.logger.startTimer(`[${tokenId}] Process tx`); result.txs.forEach(tx => { if (!localTxMap.has(tx.txid)) { localTxMap.set(tx.txid, tx); } }); // this.logger.endTimer(`[${tokenId}] Process tx`); // this.logger.startTimer(`[${tokenId}] Sorting tx order`); localTxOrder = sortTxIds(Array.from(localTxMap.keys()), key => localTxMap.get(key)); // this.logger.endTimer(`[${tokenId}] Sorting tx order`); if (localTxMap.size >= 2000) { if (iterationCount % 10 === 0) { this.logger.startTimer(`[${tokenId}] Write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(tokenId, updatedData, true); this.logger.endTimer(`[${tokenId}] Write cache`); } else { this.logger.log(`[${tokenId}] Skipping DB write to reduce overhead (iteration ${iterationCount}).`); } } else { this.logger.startTimer(`[${tokenId}] Write cache`); const updatedData = { txMap: Object.fromEntries(localTxMap), txOrder: localTxOrder, }; await this._writeCache(tokenId, updatedData, true); this.logger.endTimer(`[${tokenId}] Write cache`); } currentPage++; iterationCount++; } // 主动释放内存 // this.logger.log(`[${tokenId}] Cleaning up memory used for cache update.`); localTxMap.clear(); localTxOrder = null; const currentStatus = this._getCacheStatus(tokenId, true); if (currentStatus !== CACHE_STATUS.LATEST) { this.logger.log(`[${tokenId}] Cache update complete, setting status to LATEST.`); this._setCacheStatus(tokenId, CACHE_STATUS.LATEST, true); // 异步触发 WS 初始化,避免阻塞更新锁的释放 this._initWebsocketForToken(tokenId).catch(err => this.logger.error(err)); } else { this.logger.log(`[${tokenId}] Cache update complete, maintaining LATEST status.`); } } catch (error) { this.logger.error('[Cache] Error in _updateTokenCache:', error); throw error; } }, `updateTokenCache for ${tokenId}`); } async getTokenHistory(tokenId, pageOffset = 0, pageSize = 200) { // this.logger.log('[DEBUG] getTokenHistory called with:', { tokenId, pageOffset, pageSize }); const apiPageSize = Math.min(200, pageSize); const cachePageSize = Math.min(15000, pageSize); // this.logger.log('[DEBUG] getTokenHistory - computed:', { apiPageSize, cachePageSize }); return await this.failover.executeWithRetry(async () => { try { const currentStatus = this._getCacheStatus(tokenId, true); // If the cache is rejected, use chronik directly and add status: 2 if (currentStatus === CACHE_STATUS.REJECT) { const result = await this.chronik.tokenId(tokenId).history(pageOffset, Math.min(200, pageSize)); return { ...result, message: "Transaction count exceeds cache limit, serving directly from Chronik API", status: 2 }; } const cachedData = await this._readCache(tokenId); const metadata = await this._getGlobalMetadata(tokenId, true); const cachedCount = metadata ? metadata.numTxs : (cachedData ? cachedData.numTxs : 0); this.logger.log(`[Token ${tokenId}] Cache status: ${currentStatus}, Cached txs: ${cachedCount}`); // 检查 WebSocket 定时器状态 const wsTimeInfo = this.wsManager.getRemainingTime(tokenId); if (wsTimeInfo.active) { // this.logger.log(`[Token ${tokenId}] WebSocket remaining time: ${wsTimeInfo.remainingSec} seconds`); } else { // this.logger.log(`[Token ${tokenId}] ${wsTimeInfo.message}`); if (currentStatus === CACHE_STATUS.LATEST) { this._initWebsocketForToken(tokenId).catch(err => this.logger.error(err)); } } if (currentStatus === CACHE_STATUS.LATEST) { this.wsManager.resetWsTimer(tokenId, { isToken: true }); } if (currentStatus !== CACHE_STATUS.LATEST) { const quickResult = await this._quickGetTxCount(tokenId, 'token'); const apiNumTxs = quickResult; this.logger.log(`[Token ${tokenId}] Quick API numTxs: ${apiNumTxs}`); if (currentStatus !== CACHE_STATUS.UPDATING) { this._checkAndUpdateTokenCache(tokenId, apiNumTxs, this.defaultPageSize); } if (pageSize > 200) { return { status: 1, message: "Cache is being prepared. Please wait for cache to be ready when requesting more than 200 transactions.", numTxs: 0, txs: [], numPages: 0 }; } const apiResult = await this.chronik.tokenId(tokenId).history(pageOffset, apiPageSize); return { ...apiResult, status: 3 }; } const cachedResult = await this._getTokenPageFromCache(tokenId, pageOffset, cachePageSize); if (cachedResult) { return cachedResult; } const apiFallback = await this.chronik.tokenId(tokenId).history(pageOffset, apiPageSize); this.logger.log(`[Token ${tokenId}] API txs count (fallback): ${apiFallback.numTxs}`); return { ...apiFallback, status: 3 }; } catch (error) { this.logger.error('[Cache] Error in getTokenHistory:', error); throw error; } }, `getTokenHistory for ${tokenId}`); } tokenId(tokenId) { return { history: async (pageOffset = 0, pageSize = 200) => { return await this.getTokenHistory(tokenId, pageOffset, pageSize); } }; } // 修改 _quickGetTxCount 方法,添加重试机制 async _quickGetTxCount(identifier, type = 'address') { return await this.failover.executeWithRetry(async () => { let result; if (type === 'address') { result = await this.chronik.address(identifier).history(0, 1); } else if (type === 'token') { result = await this.chronik.tokenId(identifier).history(0, 1); } else { throw new Error(`Unsupported type: ${type}`); } return result.numTxs; }, `quickGetTxCount for ${type} ${identifier}`); } // For token cache clear, rely on dbUtils.clearTokenCache (see below) async clearTokenCache(tokenId) { await this.db.clearTokenCache(tokenId); if (typeof this.wsManager.unsubscribeToken === 'function') { this.wsManager.unsubscribeToken(tokenId); } this._setCacheStatus(tokenId, CACHE_STATUS.UNKNOWN, true); this.logger.log(`Cache cleared for token: ${tokenId}`); } // 修改 _updateUnconfirmedTx 方法,使其接受 addressOrTokenId 参数 async _updateUnconfirmedTx(addressOrTokenId, txid) { return this.txUpdateQueue.enqueue(async () => { try { const updatedTx = await this.chronik.tx(txid); let cache = await this._readCache(addressOrTokenId); if (!cache || !cache.txMap) { this.logger.log(`[${addressOrTokenId}] Cache not found or empty when updating tx`); return; } if (cache.txMap[txid]) { cache.txMap[txid] = updatedTx; cache.txOrder = sortTxIds(cache.txOrder, key => cache.txMap[key]); await this._writeCache(addressOrTokenId, cache); this.logger.log(`[${addressOrTokenId}] Updated tx ${txid} in cache`); } } catch (error) { this.logger.error(`Error updating tx ${txid} in cache:`, error); throw error; } }); } // 合并后的通用方法 async _updatePageUnconfirmedTxs(identifier, cache, txsInCurrentPage, isToken = false) { const idType = isToken ? 'token' : 'address'; let updated = false; const unconfirmedTxids = txsInCurrentPage .filter(tx => !tx.isFinal && (!tx.block || !tx.block.height)) .map(tx => tx.txid); // 使用 Promise.all 和任务队列处理所有未确认交易 await Promise.all(unconfirmedTxids.map(txid => this.txUpdateQueue.enqueue(async () => { try { const updatedTx = await this.chronik.tx(txid); if (updatedTx && updatedTx.isFinal) { cache.txMap[txid] = updatedTx; updated = true; } } catch (error) { this.logger.error(`Error updating tx ${txid} in cache:`, error); } }) )).then(() => { if (updated) { this.logger.log(`[${idType} ${identifier}] Updated some unconfirmed transactions`); } }); if (updated) { cache.txOrder = sortTxIds(cache.txOrder, key => cache.txMap[key]); await this._writeCache(identifier, cache, isToken); } } // 修改 token 的 getPageFromCache 方法 async _getTokenPageFromCache(tokenId, pageOffset, pageSize) { this.logger.startTimer(`[${tokenId}] _getTokenPageFromCache`); let cacheEntry = this.tokenMemoryCache.get(tokenId); const now = Date.now(); if (cacheEntry) { if (now > cacheEntry.expiry) { this.logger.log(`[${tokenId}] In-memory token cache entry expired`); this.tokenMemoryCache.delete(tokenId); cacheEntry = null; } else { // Use memory cache cacheEntry.expiry += 10 * 1000; this.logger.log(`[${tokenId}] Use memory cache`); } } let cache; if (cacheEntry) { cache = cacheEntry.data; } else { this.logger.startTimer(`[${tokenId}] Read persistent cache from DB`); cache = await this._readCache(tokenId); this.logger.endTimer(`[${tokenId}] Read DB cache`); if (!cache) return null; // Initial memory cache expiry time: 120 seconds this.tokenMemoryCache.set(tokenId, { data: cache, expiry: now + 120 * 1000 }); } const metadata = await this._getGlobalMetadata(tokenId, true); if (!metadata) return null; cache.txOrder = sortTxIds(cache.txOrder, key => cache.txMap[key]); // With 50% probability, compute hash and check consistency if (Math.random() < 0.5) { const newHash = computeHash(cache.txOrder); // Only output log when hash is changed if (newHash !== metadata.dataHash) { this.logger.log(`[${tokenId}] newHash: ${newHash}, stored hash: ${metadata.dataHash}`); this.logger.log(`[${tokenId}] Cache order hash mismatch detected. Triggering cache update a