UNPKG

chronik-cache

Version:

A cache helper for chronik-client

1,007 lines 54.5 kB
"use strict"; // Copyright (c) 2024 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; const dbUtils_1 = __importDefault(require("./lib/dbUtils")); const WebSocketManager_1 = __importDefault(require("./lib/WebSocketManager")); const Logger_1 = __importDefault(require("./lib/Logger")); const ecashaddrjs_1 = require("ecashaddrjs"); const constants_1 = require("./constants"); const failover_1 = __importDefault(require("./lib/failover")); const hash_1 = require("./lib/hash"); const TaskQueue_1 = __importDefault(require("./lib/TaskQueue")); const sortTxIds_1 = __importDefault(require("./lib/sortTxIds")); const CacheStats_1 = __importDefault(require("./lib/CacheStats")); const MAX_ITEMS_PER_KEY = constants_1.DEFAULT_CONFIG.MAX_ITEMS_PER_KEY; class ChronikCache { constructor(chronik, config = {}) { const { maxTxLimit = constants_1.DEFAULT_CONFIG.MAX_TX_LIMIT, maxCacheSize = constants_1.DEFAULT_CONFIG.MAX_CACHE_SIZE, failoverOptions = {}, enableLogging = true, enableTimer = false, wsTimeout = constants_1.DEFAULT_CONFIG.WS_TIMEOUT, wsExtendTimeout = constants_1.DEFAULT_CONFIG.WS_EXTEND_TIMEOUT } = config; this.chronik = chronik; this.maxTxLimit = maxTxLimit; this.defaultPageSize = constants_1.DEFAULT_CONFIG.DEFAULT_PAGE_SIZE; this.cacheDir = constants_1.DEFAULT_CONFIG.CACHE_DIR; this.maxCacheSize = maxCacheSize * 1024 * 1024; this.enableLogging = enableLogging; this.logger = new Logger_1.default(enableLogging, enableTimer); // Initialize database utilities this.db = new dbUtils_1.default(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_1.default(chronik, failoverOptions, enableLogging, { wsTimeout: wsTimeout, wsExtendTimeout: wsExtendTimeout, onEvict: (identifier, subscriptionType) => { const isToken = subscriptionType === 'token'; this._setCacheStatus(identifier, constants_1.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 failover_1.default(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 = constants_1.DEFAULT_CONFIG.GLOBAL_METADATA_CACHE_LIMIT || 100; // 初始化全局任务队列,最大并发 2 个 this.updateQueue = new TaskQueue_1.default(2); // 初始化交易更新队列,最大并发 5 个 this.txUpdateQueue = new TaskQueue_1.default(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_1.default(this, this.logger); // 启动内存缓存过期检查定时器 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: ${String(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; let txMap; // Check for paginated txOrder const txOrderMeta = await this.db.get(`${addressOrTokenId}:txOrder:meta`); if (txOrderMeta) { const { pageCount } = txOrderMeta; txOrder = []; for (let i = 0; i < pageCount; i++) { const 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 const txMapMeta = await this.db.get(`${addressOrTokenId}:txMap:meta`); if (txMapMeta) { const { pageCount } = txMapMeta; txMap = {}; for (let i = 0; i < pageCount; i++) { const 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 { 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 = (0, hash_1.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++) { const 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++) { const chunkKeys = data.txOrder.slice(i * MAX_ITEMS_PER_KEY, (i + 1) * MAX_ITEMS_PER_KEY); const 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, constants_1.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, constants_1.CACHE_STATUS.UNKNOWN); } } async _initWebsocketForToken(tokenId) { try { return await this.failover.handleWebSocketOperation(async () => { this.wsManager.resetWsTimer(tokenId, { isToken: true }, (id) => { this._setCacheStatus(id, constants_1.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, constants_1.CACHE_STATUS.UNKNOWN, true); } } /* --------------------- 缓存状态管理方法 --------------------- */ _getCacheStatus(identifier, isToken = false) { const isUpdating = this._isUpdating(identifier, isToken); if (isUpdating) { return constants_1.CACHE_STATUS.UPDATING; } const statusMap = isToken ? this.tokenStatusMap : this.statusMap; const status = statusMap.get(identifier); return status?.status ?? constants_1.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: 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, constants_1.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, constants_1.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, constants_1.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; const localCache = await this._readCache(address) || { txMap: {}, txOrder: [] }; const 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 localTxOrder = (0, sortTxIds_1.default)(Array.from(localTxMap.keys()), (key) => localTxMap.get(key)); // 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`); result.txs.forEach((tx) => { if (!localTxMap.has(tx.txid)) { localTxMap.set(tx.txid, tx); } }); // Use helper function to sort txOrder localTxOrder = (0, sortTxIds_1.default)(Array.from(localTxMap.keys()), (key) => localTxMap.get(key)); 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 localTxMap.clear(); localTxOrder = []; const currentStatus = this._getCacheStatus(address); if (currentStatus !== constants_1.CACHE_STATUS.LATEST) { this.logger.log(`[${address}] Cache update complete, setting status to LATEST.`); this._setCacheStatus(address, constants_1.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 = (0, ecashaddrjs_1.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, constants_1.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, constants_1.CACHE_STATUS.UNKNOWN); }); // Update token cache status to UNKNOWN for each token this.tokenStatusMap.forEach((_, tokenId) => { this._setCacheStatus(tokenId, constants_1.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 === constants_1.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, { isToken: false }); if (wsTimeInfo.active) { // WebSocket is active } else { // WebSocket is not active if (currentStatus === constants_1.CACHE_STATUS.LATEST) { this._initWebsocketForAddress(address); } } if (currentStatus === constants_1.CACHE_STATUS.LATEST) { this.wsManager.resetWsTimer(address, { isToken: false }); } if (currentStatus !== constants_1.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 !== constants_1.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 = undefined; } 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`); const cacheResult = await this._readCache(address); this.logger.endTimer(`[${address}] Read DB cache`); if (!cacheResult) return null; cache = cacheResult; // 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 = (0, sortTxIds_1.default)(cache.txOrder, (key) => cache.txMap[key]); // With 50% probability, compute hash and check consistency if (Math.random() < 0.5) { const newHash = (0, hash_1.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; let txs = cache.txOrder.slice(start, end).map(txid => cache.txMap[txid]); await this._updatePageUnconfirmedTxs(address, cache, txs, false); // 重新获取可能已更新的交易数据 txs = cache.txOrder.slice(start, end).map(txid => cache.txMap[txid]); 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, constants_1.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, constants_1.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; const localCache = await this._readCache(tokenId) || { txMap: {}, txOrder: [] }; const 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 localTxOrder = (0, sortTxIds_1.default)(Array.from(localTxMap.keys()), (key) => localTxMap.get(key)); // 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`); result.txs.forEach((tx) => { if (!localTxMap.has(tx.txid)) { localTxMap.set(tx.txid, tx); } }); localTxOrder = (0, sortTxIds_1.default)(Array.from(localTxMap.keys()), (key) => localTxMap.get(key)); 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++; } // 主动释放内存 localTxMap.clear(); localTxOrder = []; const currentStatus = this._getCacheStatus(tokenId, true); if (currentStatus !== constants_1.CACHE_STATUS.LATEST) { this.logger.log(`[${tokenId}] Cache update complete, setting status to LATEST.`); this._setCacheStatus(tokenId, constants_1.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) { const apiPageSize = Math.min(200, pageSize); const cachePageSize = Math.min(15000, pageSize); 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 === constants_1.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, { isToken: true }); if (wsTimeInfo.active) { // WebSocket is active } else { // WebSocket is not active if (currentStatus === constants_1.CACHE_STATUS.LATEST) { this._initWebsocketForToken(tokenId).catch(err => this.logger.error(err)); } } if (currentStatus === constants_1.CACHE_STATUS.LATEST) { this.wsManager.resetWsTimer(tokenId, { isToken: true }); } if (currentStatus !== constants_1.CACHE_STATUS.LATEST) { const quickResult = await this._quickGetTxCount(tokenId, 'token'); const apiNumTxs = quickResult; this.logger.log(`[Token ${tokenId}] Quick API numTxs: ${apiNumTxs}`); if (currentStatus !== constants_1.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, constants_1.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); const 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 = (0, sortTxIds_1.default)(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; // 筛选未确认交易(没有block.height字段的交易) const unconfirmedTxids = txsInCurrentPage .filter(tx => !tx.block || !tx.block.height) .map(tx => tx.txid); if (unconfirmedTxids.length === 0) { return; // 没有未确认交易,直接返回 } this.logger.log(`[${idType} ${identifier}] Found ${unconfirmedTxids.length} unconfirmed transactions to check`); // 处理所有未确认交易 await Promise.all(unconfirmedTxids.map(txid => this.txUpdateQueue.enqueue(async () => { try { const updatedTx = await this.chronik.tx(txid); // 检查交易是否已确认(有block.height字段) if (updatedTx && updatedTx.block && updatedTx.block.height) { cache.txMap[txid] = updatedTx; updated = true; this.logger.log(`[${idType} ${identifier}] Tx ${txid} confirmed at height ${updatedTx.block.height}`); } } catch (error) { this.logger.error(`Error updating tx ${txid} in cache:`, error); } }))); if (updated) { cache.txOrder = (0, sortTxIds_1.default)(cache.txOrder, (key) => cache.txMap[key]); await this._writeCache(identifier, cache, isToken); this.logger.log(`[${idType} ${identifier}] Cache updated with newly confirmed transactions`); } } // 修改 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 = undefined; } 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`); const cacheResult = await this._readCache(tokenId); this.logger.endTimer(`[${tokenId}] Read DB cache`); if (!cacheResult) return null; cache = cacheResult; // 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 = (0, sortTxIds_1.default)(cache.txOrder, (key) => cache.txMap[key]); // With 50% probability, compute hash and check consistency if (Math.random() < 0.5) { const newHash = (0, hash_1.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