chronik-cache
Version:
A cache helper for chronik-client
1,082 lines (953 loc) • 57.2 kB
text/typescript
// 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.
import DbUtils from './lib/dbUtils';
import WebSocketManager from './lib/WebSocketManager';
import Logger from './lib/Logger';
import { encodeCashAddress } from 'ecashaddrjs';
import { CACHE_STATUS, DEFAULT_CONFIG } from './constants';
import FailoverHandler from './lib/failover';
import { computeHash } from './lib/hash';
import TaskQueue from './lib/TaskQueue';
import sortTxIds from './lib/sortTxIds';
import CacheStats from './lib/CacheStats';
import {
ChronikCacheConfig,
Transaction,
HistoryResponse,
CacheData,
CacheMetadata,
CacheStatusInfo,
WebSocketMessageType,
CacheStatistics,
ChronikClientInterface,
MemoryCacheEntry
} from './types';
const MAX_ITEMS_PER_KEY = DEFAULT_CONFIG.MAX_ITEMS_PER_KEY;
class ChronikCache {
private chronik: ChronikClientInterface;
private maxTxLimit: number;
private defaultPageSize: number;
private cacheDir: string;
private maxCacheSize: number;
private enableLogging: boolean;
private logger: any;
public db: any;
private statusMap: Map<string, CacheStatusInfo>;
public wsManager: any;
private updateLocks: Map<string, boolean>;
private scriptToAddressMap: Map<string, string>;
private failover: any;
private tokenStatusMap: Map<string, CacheStatusInfo>;
private tokenUpdateLocks: Map<string, boolean>;
public globalMetadataCache: Map<string, CacheMetadata>;
public globalMetadataCacheLimit: number;
public updateQueue: any;
public txUpdateQueue: any;
private addressMemoryCache: Map<string, MemoryCacheEntry<CacheData>>;
private tokenMemoryCache: Map<string, MemoryCacheEntry<CacheData>>;
private stats: any;
private debounceTimers: Map<string, NodeJS.Timeout>;
private memoryCacheCleanupInterval?: NodeJS.Timeout;
constructor(chronik: ChronikClientInterface, config: ChronikCacheConfig = {}) {
const {
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
} = config;
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<string, CacheStatusInfo>();
// Pass onEvict callback to update cache status to UNKNOWN
this.wsManager = new WebSocketManager(chronik, failoverOptions, enableLogging, {
wsTimeout: wsTimeout as any,
wsExtendTimeout: wsExtendTimeout as any,
onEvict: (identifier: string, subscriptionType: string) => {
const isToken = subscriptionType === 'token';
this._setCacheStatus(identifier, CACHE_STATUS.UNKNOWN, isToken);
}
});
this.updateLocks = new Map<string, boolean>();
// Add script type to address cache mapping
this.scriptToAddressMap = new Map<string, string>();
// Add failover handler
this.failover = new FailoverHandler(failoverOptions);
// 添加token缓存相关的Map
this.tokenStatusMap = new Map<string, CacheStatusInfo>();
this.tokenUpdateLocks = new Map<string, boolean>();
// 初始化全局元数据缓存,防止频繁访问数据库
this.globalMetadataCache = new Map<string, CacheMetadata>();
// 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<string, MemoryCacheEntry<CacheData>>();
this.tokenMemoryCache = new Map<string, MemoryCacheEntry<CacheData>>();
// =========================================================
// Initialize stats
this.stats = new CacheStats(this, this.logger);
// 启动内存缓存过期检查定时器
this._startMemoryCacheExpirationCheckTimer();
// 添加防抖计时器Map
this.debounceTimers = new Map<string, NodeJS.Timeout>();
return new Proxy(this, {
get: (target: any, prop: string | symbol) => {
// 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 as any)[prop] === 'function') {
return (...args: any[]) => {
this.logger.log(`Forwarding uncached method call: ${String(prop)}`);
return Promise.resolve((chronik as any)[prop](...args)).then((result: any) => {
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 as any)[prop];
}
return undefined;
}
});
}
// 添加防抖工具方法
private _debounce(key: string, fn: () => Promise<void>, delay: number = 500): void {
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
private async _readCache(addressOrTokenId: string): Promise<CacheData | null> {
try {
let txOrder: string[];
let txMap: Record<string, Transaction>;
// 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
private async _writeCache(addressOrTokenId: string, data: CacheData, isToken: boolean = false): Promise<void> {
// 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++) {
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: Record<string, Transaction> = {};
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}`);
}
// 更新全局元数据
private async _updateGlobalMetadata(identifier: string, metadata: CacheMetadata, isToken: boolean = false): Promise<void> {
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();
}
// 获取全局元数据
public async _getGlobalMetadata(identifier: string, isToken: boolean = false): Promise<CacheMetadata | null> {
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 --------------------- */
private async _initWebsocketForAddress(address: string): Promise<void> {
try {
return await this.failover.handleWebSocketOperation(async () => {
this.wsManager.resetWsTimer(address, { isToken: false }, (addr: string) => {
this._setCacheStatus(addr, CACHE_STATUS.UNKNOWN);
this._resetMemoryCache(addr, false);
});
await this.wsManager.initWebsocketForAddress(address, async (addr: string, txid: string, msgType: WebSocketMessageType) => {
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);
}
}
private async _initWebsocketForToken(tokenId: string): Promise<void> {
try {
return await this.failover.handleWebSocketOperation(async () => {
this.wsManager.resetWsTimer(tokenId, { isToken: true }, (id: string) => {
this._setCacheStatus(id, CACHE_STATUS.UNKNOWN, true);
this._resetMemoryCache(id, true);
});
await this.wsManager.initWebsocketForToken(tokenId, async (id: string, txid: string, msgType: WebSocketMessageType) => {
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);
}
}
/* --------------------- 缓存状态管理方法 --------------------- */
private _getCacheStatus(identifier: string, isToken: boolean = false): string {
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;
}
private _setCacheStatus(identifier: string, status: string, isToken: boolean = false): void {
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 as any,
cacheTimestamp: existingStatus?.cacheTimestamp || now
});
}
/* --------------------- Core Cache Update Logic --------------------- */
private _isUpdating(identifier: string, isToken: boolean = false): boolean {
const updateMap = isToken ? this.tokenUpdateLocks : this.updateLocks;
return updateMap.has(identifier);
}
// 修改检查限制的方法名和实现
private _checkTxLimit(identifier: string, numTxs: number, isToken: boolean = false): boolean {
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;
}
private async _checkAndUpdateCache(address: string, apiNumTxs: number, pageSize: number, forceUpdate: boolean = false): Promise<void> {
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);
}
});
}
private async _updateCache(address: string, totalNumTxs: number, pageSize: number): Promise<void> {
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 = sortTxIds(Array.from(localTxMap.keys()), (key: string) => localTxMap.get(key));
// Always write the final state into the cache
this.logger.startTimer(`[${address}] Final write cache`);
const updatedData: CacheData = {
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: Transaction) => {
if (!localTxMap.has(tx.txid)) {
localTxMap.set(tx.txid, tx);
}
});
// Use helper function to sort txOrder
localTxOrder = sortTxIds(Array.from(localTxMap.keys()), (key: string) => localTxMap.get(key));
if (localTxMap.size >= 2000) {
if (iterationCount % 10 === 0) {
this.logger.startTimer(`[${address}] Write cache`);
const updatedData: CacheData = {
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: CacheData = {
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 !== 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
private _convertScriptToAddress(type: string, hash: string): string {
try {
// Ensure hash is lowercase
hash = hash.toLowerCase();
// Use ecashaddrjs to convert script to ecash address
const address = encodeCashAddress('ecash', type as any, 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
public script(type: string, hash: string) {
return {
history: async (pageOffset: number = 0, pageSize: number = 200): Promise<HistoryResponse> => {
// 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
public async clearAddressCache(address: string): Promise<void> {
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}`);
}
public async clearAllCache(): Promise<void> {
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);
}
}
public async getAddressHistory(address: string, pageOffset: number = 0, pageSize: number = 200): Promise<HistoryResponse> {
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, { isToken: false });
if (wsTimeInfo.active) {
// WebSocket is active
} else {
// WebSocket is not active
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}`);
}
private async _getPageFromCache(address: string, pageOffset: number, pageSize: number): Promise<HistoryResponse | null> {
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: CacheData;
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 = sortTxIds(cache.txOrder, (key: string) => 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;
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!
};
}
public address(address: string) {
return {
history: async (pageOffset: number = 0, pageSize: number = 200): Promise<HistoryResponse> => {
return await this.getAddressHistory(address, pageOffset, pageSize);
}
// Add other methods here if needed
};
}
/* --------------------- Token Related Methods --------------------- */
private async _checkAndUpdateTokenCache(tokenId: string, apiNumTxs: number, pageSize: number, forceUpdate: boolean = false): Promise<void> {
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);
}
});
}
private async _updateTokenCache(tokenId: string, totalNumTxs: number, pageSize: number): Promise<void> {
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 = sortTxIds(Array.from(localTxMap.keys()), (key: string) => localTxMap.get(key));
// Always write the final state into the cache
this.logger.startTimer(`[${tokenId}] Final write cache`);
const updatedData: CacheData = {
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: Transaction) => {
if (!localTxMap.has(tx.txid)) {
localTxMap.set(tx.txid, tx);
}
});
localTxOrder = sortTxIds(Array.from(localTxMap.keys()), (key: string) => localTxMap.get(key));
if (localTxMap.size >= 2000) {
if (iterationCount % 10 === 0) {
this.logger.startTimer(`[${tokenId}] Write cache`);
const updatedData: CacheData = {
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: CacheData = {
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 !== 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}`);
}
public async getTokenHistory(tokenId: string, pageOffset: number = 0, pageSize: number = 200): Promise<HistoryResponse> {
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 === 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 === 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}`);
}
public tokenId(tokenId: string) {
return {
history: async (pageOffset: number = 0, pageSize: number = 200): Promise<HistoryResponse> => {
return await this.getTokenHistory(tokenId, pageOffset, pageSize);
}
};
}
// 修改 _quickGetTxCount 方法,添加重试机制
private async _quickGetTxCount(identifier: string, type: 'address' | 'token' = 'address'): Promise<number> {
return await this.failover.executeWithRetry(async () => {
let result: HistoryResponse;
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)
public async clearTokenCache(tokenId: string): Promise<void> {
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 参数
private async _updateUnconfirmedTx(addressOrTokenId: string, txid: string): Promise<void> {
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 = sortTxIds(cache.txOrder, (key: string) => 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;
}
});
}
// 合并后的通用方法
private async _updatePageUnconfirmedTxs(identifier: string, cache: CacheData, txsInCurrentPage: Transaction[], isToken: boolean = false): Promise<void> {
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} confi