UNPKG

@cityofzion/neo-js

Version:

Running NEO blockchain full node with Node.js and MongoDB.

436 lines (377 loc) 15.2 kB
import { EventEmitter } from 'events' import { priorityQueue, AsyncPriorityQueue } from 'async' import { Logger, LoggerOptions } from 'node-log-it' import { merge, map, difference, filter, take } from 'lodash' import { Node } from './node' import { Mesh } from './mesh' import { MemoryStorage } from '../storages/memory-storage' import { MongodbStorage } from '../storages/mongodb-storage' // import C from '../common/constants' const MODULE_NAME = 'Syncer' const DEFAULT_OPTIONS: SyncerOptions = { minHeight: 1, maxHeight: undefined, blockRedundancy: 1, // If value is greater than 1, than it'll keep multiple copies of same block as integrity measurement // TODO: to ensure redundant blocks are coming from unique sources checkRedundancyBeforeStoreBlock: true, // Perform a count on given height before attempt to store block. startOnInit: true, toSyncIncremental: true, toSyncForMissingBlocks: true, toPruneRedundantBlocks: true, storeQueueConcurrency: 30, pruneQueueConcurrency: 10, enqueueBlockIntervalMs: 5000, verifyBlocksIntervalMs: 1 * 60 * 1000, maxStoreQueueLength: 1000, retryEnqueueDelayMs: 5000, standardEnqueueBlockPriority: 5, retryEnqueueBlockPriority: 3, missingEnqueueStoreBlockPriority: 1, enqueuePruneBlockPriority: 5, maxPruneChunkSize: 1000, loggerOptions: {}, } export interface SyncerOptions { minHeight?: number maxHeight?: number blockRedundancy?: number checkRedundancyBeforeStoreBlock?: boolean startOnInit?: boolean toSyncIncremental?: boolean toSyncForMissingBlocks?: boolean toPruneRedundantBlocks?: boolean storeQueueConcurrency?: number pruneQueueConcurrency?: number enqueueBlockIntervalMs?: number verifyBlocksIntervalMs?: number maxStoreQueueLength?: number retryEnqueueDelayMs?: number standardEnqueueBlockPriority?: number retryEnqueueBlockPriority?: number missingEnqueueStoreBlockPriority?: number enqueuePruneBlockPriority?: number maxPruneChunkSize?: number loggerOptions?: LoggerOptions } export class Syncer extends EventEmitter { private _isRunning = false private storeQueue: AsyncPriorityQueue<object> private pruneQueue: AsyncPriorityQueue<object> private blockWritePointer: number = 0 private mesh: Mesh private storage?: MemoryStorage | MongodbStorage private options: SyncerOptions private logger: Logger private enqueueStoreBlockIntervalId?: NodeJS.Timer private blockVerificationIntervalId?: NodeJS.Timer private isVerifyingBlocks = false constructor(mesh: Mesh, storage?: MemoryStorage | MongodbStorage, options: SyncerOptions = {}) { super() // Associate required properties this.mesh = mesh this.storage = storage // Associate optional properties this.options = merge({}, DEFAULT_OPTIONS, options) this.validateOptionalParameters() // Bootstrapping this.logger = new Logger(MODULE_NAME, this.options.loggerOptions) this.storeQueue = this.getPriorityQueue(this.options.storeQueueConcurrency!) this.pruneQueue = this.getPriorityQueue(this.options.pruneQueueConcurrency!) if (this.options.startOnInit) { this.start() } // Event handlers this.on('storeBlock:complete', this.storeBlockCompleteHandler.bind(this)) this.logger.debug('constructor completes.') } isRunning(): boolean { return this._isRunning } start() { if (this._isRunning) { this.logger.info('Syncer has already started.') return } if (!this.storage) { this.logger.info('Unable to start syncer when no storage are defined.') return } this.logger.info('Start syncer. minHeight:', this.options.minHeight!, 'maxHeight:', this.options.maxHeight) this._isRunning = true this.emit('start') this.initStoreBlock() this.initBlockVerification() } stop() { if (!this._isRunning) { this.logger.info('Syncer is not running at the moment.') return } this.logger.info('Stop syncer.') this._isRunning = false this.emit('stop') clearInterval(this.enqueueStoreBlockIntervalId!) clearInterval(this.blockVerificationIntervalId!) } close() { this.stop() } private storeBlockCompleteHandler(payload: any) { if (payload.isSuccess === false) { this.logger.debug('storeBlockCompleteHandler !isSuccess triggered.') setTimeout(() => { // Re-queue the method when failed after an injected delay this.enqueueStoreBlock(payload.height, this.options.retryEnqueueBlockPriority!) }, this.options.retryEnqueueDelayMs!) } } private validateOptionalParameters() { if (!this.options.blockRedundancy) { throw new Error('blockRedundancy parameter must be supplied.') } else if (this.options.blockRedundancy !== 1) { throw new Error('supplied blockRedundancy parameter is invalid. Currently only supports for value [1].') } } private getPriorityQueue(concurrency: number): AsyncPriorityQueue<object> { return priorityQueue((task: object, callback: () => void) => { const method: (attrs: object) => Promise<any> = (task as any).method const attrs: object = (task as any).attrs const meta: object = (task as any).meta this.logger.debug('New worker for queue. meta:', meta, 'attrs:', attrs) method(attrs) .then(() => { callback() this.logger.debug('Worker queued method completed.') this.emit('queue:worker:complete', { isSuccess: true, task }) }) .catch((err: any) => { this.logger.info('Worker queued method failed, but to continue... meta:', meta, 'attrs:', attrs, 'Message:', err.message) callback() this.emit('queue:worker:complete', { isSuccess: false, task }) }) }, concurrency) } private initStoreBlock() { this.logger.debug('initStoreBlock triggered.') this.setBlockWritePointer() .then(() => { if (this.options.toSyncIncremental) { // Enqueue blocks for download periodically this.enqueueStoreBlockIntervalId = setInterval(() => { this.doEnqueueStoreBlock() }, this.options.enqueueBlockIntervalMs!) } }) .catch((err: any) => { this.logger.warn('setBlockWritePointer() failed. Error:', err.message) }) } private doEnqueueStoreBlock() { this.logger.debug('doEnqueueStoreBlock triggered.') if (this.isReachedMaxHeight()) { this.logger.info(`BlockWritePointer is greater or equal to designated maxHeight [${this.options.maxHeight}]. There will be no enqueue block beyond this point.`) return } const node = this.mesh.getHighestNode() if (node) { // TODO: better way to validate a node // TODO: undefined param handler while (!this.isReachedMaxHeight() && !this.isReachedHighestBlock(node) && !this.isReachedMaxStoreQueueLength()) { this.increaseBlockWritePointer() this.enqueueStoreBlock(this.blockWritePointer!, this.options.standardEnqueueBlockPriority!) } } else { this.logger.error('Unable to find a valid node.') } } private isReachedMaxHeight(): boolean { return !!(this.options.maxHeight && this.blockWritePointer >= this.options.maxHeight) } private isReachedHighestBlock(node: Node): boolean { return this.blockWritePointer! >= node.blockHeight! } private isReachedMaxStoreQueueLength(): boolean { return this.storeQueue.length() >= this.options.maxStoreQueueLength! } private async setBlockWritePointer(): Promise<void> { this.logger.debug('setBlockWritePointer triggered.') try { const height = await this.storage!.getHighestBlockHeight() this.logger.debug('getHighestBlockHeight() success. height:', height) if (this.options.minHeight && height < this.options.minHeight) { this.logger.info(`storage height is smaller than designated minHeight. BlockWritePointer will be set to minHeight [${this.options.minHeight}] instead.`) this.blockWritePointer = this.options.minHeight } else { this.blockWritePointer = height } } catch (err) { this.logger.warn('storage.getHighestBlockHeight() failed. Error:', err.message) this.logger.info('Assumed that there are no blocks.') this.blockWritePointer = this.options.minHeight! } } private initBlockVerification() { this.logger.debug('initEnqueueBlock triggered.') this.blockVerificationIntervalId = setInterval(() => { this.doBlockVerification() }, this.options.verifyBlocksIntervalMs!) } private async doBlockVerification() { this.logger.debug('doBlockVerification triggered.') this.emit('blockVerification:init') // Queue sizes this.logger.info('storeQueue.length:', this.storeQueue.length()) this.logger.info('pruneQueue.length:', this.pruneQueue.length()) // Check if this process is currently executing if (this.isVerifyingBlocks) { this.logger.info('doBlockVerification() is already running. Skip this turn.') this.emit('blockVerification:complete', { isSkipped: true }) return } // Blocks analysis this.isVerifyingBlocks = true const startHeight = this.options.minHeight! const endHeight = this.options.maxHeight && this.blockWritePointer > this.options.maxHeight ? this.options.maxHeight : this.blockWritePointer this.logger.debug('Analyzing blocks in storage...') let blockReport: any try { blockReport = await this.storage!.analyzeBlocks(startHeight, endHeight) this.logger.debug('Analyzing blocks complete!') } catch (err) { this.logger.info('storage.analyzeBlocks error, but to continue... Message:', err.message) this.emit('blockVerification:complete', { isSuccess: false }) this.isVerifyingBlocks = false return } const all: number[] = [] for (let i = startHeight; i <= endHeight; i++) { all.push(i) } const availableBlocks: number[] = map(blockReport, (item: any) => item._id) this.logger.info('Blocks available count:', availableBlocks.length) // Enqueue missing block heights const missingBlocks = difference(all, availableBlocks) this.logger.info('Blocks missing count:', missingBlocks.length) this.emit('blockVerification:missingBlocks', { count: missingBlocks.length }) if (this.options.toSyncForMissingBlocks) { missingBlocks.forEach((height: number) => { this.enqueueStoreBlock(height, this.options.missingEnqueueStoreBlockPriority!) }) } // Request pruning of excessive blocks const excessiveBlocks = map(filter(blockReport, (item: any) => item.count > this.options.blockRedundancy!), (item: any) => item._id) this.logger.info('Blocks excessive redundancy count:', excessiveBlocks.length) this.emit('blockVerification:excessiveBlocks', { count: excessiveBlocks.length }) if (this.options.toPruneRedundantBlocks) { const takenBlocks = take(excessiveBlocks, this.options.maxPruneChunkSize!) takenBlocks.forEach((height: number) => { this.enqueuePruneBlock(height, this.options.blockRedundancy!, this.options.enqueuePruneBlockPriority!) }) } // Enqueue for redundancy blocks if (this.options.blockRedundancy! > 1) { const insufficientBlocks = map(filter(blockReport, (item: any) => item.count < this.options.blockRedundancy!), (item: any) => item._id) this.logger.info('Blocks insufficient redundancy count:', insufficientBlocks.length) // TODO throw new Error('Not Implemented.') } // Check if fully sync'ed const node = this.mesh.getHighestNode() if (node) { if (this.isReachedMaxHeight() || this.isReachedHighestBlock(node)) { if (missingBlocks.length === 0) { this.logger.info('Storage is fully synced and up to date.') this.emit('upToDate') } } } // Conclude this.isVerifyingBlocks = false this.emit('blockVerification:complete', { isSuccess: true }) } private increaseBlockWritePointer() { this.logger.debug('increaseBlockWritePointer triggered.') this.blockWritePointer += 1 } /** * @param priority Lower value, the higher its priority to be executed. */ private enqueueStoreBlock(height: number, priority: number) { this.logger.debug('enqueueStoreBlock triggered. height:', height, 'priority:', priority) // if the block height is above the current height, increment the write pointer. if (height > this.blockWritePointer) { this.logger.debug('height > this.blockWritePointer, blockWritePointer is now:', height) this.blockWritePointer = height } // enqueue the block this.storeQueue.push( { method: this.storeBlock.bind(this), attrs: { height, }, meta: { methodName: 'storeBlock', }, }, priority ) } /** * @param priority Lower value, the higher its priority to be executed. */ private enqueuePruneBlock(height: number, redundancySize: number, priority: number) { this.logger.debug('enqueuePruneBlock triggered. height:', height, 'redundancySize:', redundancySize, 'priority:', priority) this.pruneQueue.push( { method: this.pruneBlock.bind(this), attrs: { height, redundancySize, }, meta: { methodName: 'pruneBlock', }, }, priority ) } private async storeBlock(attrs: object): Promise<any> { this.logger.debug('storeBlock triggered. attrs:', attrs) const height: number = (attrs as any).height const node = this.mesh.getOptimalNode(height) this.emit('storeBlock:init', { height }) try { // Validate redundancy level if (this.options.checkRedundancyBeforeStoreBlock) { const redundantCount = await this.storage!.countBlockRedundancy(height) if (redundantCount >= this.options.blockRedundancy!) { this.logger.debug('setBlock skipped. height:', height) this.emit('storeBlock:complete', { isSkipped: true, height }) return } } // Validate dependencies if (!node) { this.emit('storeBlock:complete', { isSuccess: false, height }) throw new Error('No valid node found.') } // Fetch and store block const block = await node!.getBlock(height) const source = node!.endpoint const userAgent = node!.userAgent await this.storage!.setBlock(height, block, { source, userAgent }) this.logger.debug('storeBlock succeeded. height:', height) this.emit('storeBlock:complete', { isSuccess: true, height }) } catch (err) { this.logger.debug('storeBlock failed. height:', height, 'Message:', err.message) this.emit('storeBlock:complete', { isSuccess: false, height }) throw err } } private async pruneBlock(attrs: object): Promise<void> { this.logger.debug('pruneBlock triggered. attrs:', attrs) const height: number = (attrs as any).height const redundancySize: number = (attrs as any).redundancySize await this.storage!.pruneBlock(height, redundancySize) } }