@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
396 lines • 18 kB
JavaScript
import { ErrorAborted, toRootHex } from "@lodestar/utils";
import { BlockInputType } from "../../chain/blocks/types.js";
import { PeerAction, prettyPrintPeerIdStr } from "../../network/index.js";
import { ItTrigger } from "../../util/itTrigger.js";
import { wrapError } from "../../util/wrapError.js";
import { BATCH_BUFFER_SIZE, EPOCHS_PER_BATCH } from "../constants.js";
import { Batch, BatchError, BatchErrorCode, BatchStatus } from "./batch.js";
import { ChainPeersBalancer, batchStartEpochIsAfterSlot, computeMostCommonTarget, getBatchSlotRange, getNextBatchToProcess, isSyncChainDone, toArr, toBeDownloadedStartEpoch, validateBatchesStatus, } from "./utils/index.js";
export class SyncChainStartError extends Error {
}
export var SyncChainStatus;
(function (SyncChainStatus) {
SyncChainStatus["Stopped"] = "Stopped";
SyncChainStatus["Syncing"] = "Syncing";
SyncChainStatus["Done"] = "Done";
SyncChainStatus["Error"] = "Error";
})(SyncChainStatus || (SyncChainStatus = {}));
/**
* Dynamic target sync chain. Peers with multiple targets but with the same syncType are added
* through the `addPeer()` hook.
*
* A chain of blocks that need to be downloaded. Peers who claim to contain the target head
* root are grouped into the peer pool and queried for batches when downloading the chain.
*/
export class SyncChain {
constructor(initialBatchEpoch, initialTarget, syncType, fns, modules) {
/** Number of validated epochs. For the SyncRange to prevent switching chains too fast */
this.validatedEpochs = 0;
this.status = SyncChainStatus.Stopped;
/** AsyncIterable that guarantees processChainSegment is run only at once at anytime */
this.batchProcessor = new ItTrigger();
/** Sorted map of batches undergoing some kind of processing. */
this.batches = new Map();
this.peerset = new Map();
this.firstBatchEpoch = initialBatchEpoch;
this.lastEpochWithProcessBlocks = initialBatchEpoch;
this.target = initialTarget;
this.syncType = syncType;
this.processChainSegment = fns.processChainSegment;
this.downloadBeaconBlocksByRange = fns.downloadBeaconBlocksByRange;
this.reportPeer = fns.reportPeer;
this.config = modules.config;
this.logger = modules.logger;
this.logId = `${syncType}`;
// Trigger event on parent class
this.sync().then(() => fns.onEnd(null, this.target), (e) => fns.onEnd(e, null));
}
/**
* Start syncing a new chain or an old one with an existing peer list
* In the same call, advance the chain if localFinalizedEpoch >
*/
startSyncing(localFinalizedEpoch) {
switch (this.status) {
case SyncChainStatus.Stopped:
break; // Ok, continue
case SyncChainStatus.Syncing:
return; // Skip, already started
case SyncChainStatus.Error:
case SyncChainStatus.Done:
throw new SyncChainStartError(`Attempted to start an ended SyncChain ${this.status}`);
}
this.status = SyncChainStatus.Syncing;
this.logger.debug("SyncChain startSyncing", {
localFinalizedEpoch,
lastEpochWithProcessBlocks: this.lastEpochWithProcessBlocks,
targetSlot: this.target.slot,
});
// to avoid dropping local progress, we advance the chain with its batch boundaries.
// get the aligned epoch that produces a batch containing the `localFinalizedEpoch`
const lastEpochWithProcessBlocksAligned = this.lastEpochWithProcessBlocks +
Math.floor((localFinalizedEpoch - this.lastEpochWithProcessBlocks) / EPOCHS_PER_BATCH) * EPOCHS_PER_BATCH;
this.advanceChain(lastEpochWithProcessBlocksAligned);
// Potentially download new batches and process pending
this.triggerBatchDownloader();
this.triggerBatchProcessor();
}
/**
* Temporarily stop the chain. Will prevent batches from being processed
*/
stopSyncing() {
this.status = SyncChainStatus.Stopped;
}
/**
* Permanently remove this chain. Throws the main AsyncIterable
*/
remove() {
this.batchProcessor.end(new ErrorAborted("SyncChain"));
}
/**
* Add peer to the chain and request batches if active
*/
addPeer(peer, target) {
this.peerset.set(peer, target);
this.computeTarget();
this.triggerBatchDownloader();
}
/**
* Returns true if the peer existed and has been removed
* NOTE: The RangeSync will take care of deleting the SyncChain if peers = 0
*/
removePeer(peerId) {
const deleted = this.peerset.delete(peerId);
this.computeTarget();
return deleted;
}
/**
* Helper to print internal state for debugging when chain gets stuck
*/
getBatchesState() {
return toArr(this.batches).map((batch) => batch.getMetadata());
}
get lastValidatedSlot() {
// Last epoch of the batch after the last one validated
return getBatchSlotRange(this.lastEpochWithProcessBlocks + EPOCHS_PER_BATCH).startSlot - 1;
}
get isSyncing() {
return this.status === SyncChainStatus.Syncing;
}
get isRemovable() {
return this.status === SyncChainStatus.Error || this.status === SyncChainStatus.Done;
}
get peers() {
return this.peerset.size;
}
getPeers() {
return Array.from(this.peerset.keys());
}
/** Full debug state for lodestar API */
getDebugState() {
return {
targetRoot: toRootHex(this.target.root),
targetSlot: this.target.slot,
syncType: this.syncType,
status: this.status,
startEpoch: this.lastEpochWithProcessBlocks,
peers: this.peers,
batches: this.getBatchesState(),
};
}
computeTarget() {
if (this.peerset.size > 0) {
const targets = Array.from(this.peerset.values());
this.target = computeMostCommonTarget(targets);
}
}
/**
* Main Promise that handles the sync process. Will resolve when initial sync completes
* i.e. when it successfully processes a epoch >= than this chain `targetEpoch`
*/
async sync() {
try {
// Start processing batches on demand in strict sequence
for await (const _ of this.batchProcessor) {
if (this.status !== SyncChainStatus.Syncing) {
continue;
}
// TODO: Consider running this check less often after the sync is well tested
validateBatchesStatus(toArr(this.batches));
// Returns true if SyncChain has processed all possible blocks with slot <= target.slot
if (isSyncChainDone(toArr(this.batches), this.lastEpochWithProcessBlocks, this.target.slot)) {
break;
}
// Processes the next batch if ready
const batch = getNextBatchToProcess(toArr(this.batches));
if (batch)
await this.processBatch(batch);
}
this.status = SyncChainStatus.Done;
this.logger.verbose("SyncChain Done", { id: this.logId });
}
catch (e) {
if (e instanceof ErrorAborted) {
return; // Ignore
}
this.status = SyncChainStatus.Error;
this.logger.verbose("SyncChain Error", { id: this.logId }, e);
// If a batch exceeds it's retry limit, maybe downscore peers.
// shouldDownscoreOnBatchError() functions enforces that all BatchErrorCode values are covered
if (e instanceof BatchError) {
const shouldReportPeer = shouldReportPeerOnBatchError(e.type.code);
if (shouldReportPeer) {
for (const peer of this.peerset.keys()) {
this.reportPeer(peer, shouldReportPeer.action, shouldReportPeer.reason);
}
}
}
throw e;
}
}
/**
* Request to process batches if possible
*/
triggerBatchProcessor() {
this.batchProcessor.trigger();
}
/**
* Request to download batches if possible
* Backlogs requests into a single pending request
*/
triggerBatchDownloader() {
try {
this.requestBatches(Array.from(this.peerset.keys()));
}
catch (e) {
// bubble the error up to the main async iterable loop
this.batchProcessor.end(e);
}
}
/**
* Attempts to request the next required batches from the peer pool if the chain is syncing.
* It will exhaust the peer pool and left over batches until the batch buffer is reached.
*/
requestBatches(peers) {
if (this.status !== SyncChainStatus.Syncing) {
return;
}
const peerBalancer = new ChainPeersBalancer(peers, toArr(this.batches));
// Retry download of existing batches
for (const batch of this.batches.values()) {
if (batch.state.status !== BatchStatus.AwaitingDownload) {
continue;
}
const peer = peerBalancer.bestPeerToRetryBatch(batch);
if (peer) {
void this.sendBatch(batch, peer);
}
}
// find the next pending batch and request it from the peer
for (const peer of peerBalancer.idlePeers()) {
const batch = this.includeNextBatch();
if (!batch) {
break;
}
void this.sendBatch(batch, peer);
}
}
/**
* Creates the next required batch from the chain. If there are no more batches required, returns `null`.
*/
includeNextBatch() {
const batches = toArr(this.batches);
// Only request batches up to the buffer size limit
// Note: Don't count batches in the AwaitingValidation state, to prevent stalling sync
// if the current processing window is contained in a long range of skip slots.
const batchesInBuffer = batches.filter((batch) => {
return batch.state.status === BatchStatus.Downloading || batch.state.status === BatchStatus.AwaitingProcessing;
});
if (batchesInBuffer.length > BATCH_BUFFER_SIZE) {
return null;
}
// This line decides the starting epoch of the next batch. MUST ensure no duplicate batch for the same startEpoch
const startEpoch = toBeDownloadedStartEpoch(batches, this.lastEpochWithProcessBlocks);
// Don't request batches beyond the target head slot. The to-be-downloaded batch must be strictly after target.slot
if (batchStartEpochIsAfterSlot(startEpoch, this.target.slot)) {
return null;
}
if (this.batches.has(startEpoch)) {
this.logger.error("Attempting to add existing Batch to SyncChain", { id: this.logId, startEpoch });
return null;
}
const batch = new Batch(startEpoch, this.config);
this.batches.set(startEpoch, batch);
return batch;
}
/**
* Requests the batch assigned to the given id from a given peer.
*/
async sendBatch(batch, peer) {
try {
batch.startDownloading(peer);
// wrapError ensures to never call both batch success() and batch error()
const res = await wrapError(this.downloadBeaconBlocksByRange(peer, batch.request));
if (!res.err) {
batch.downloadingSuccess(res.result);
let hasPostDenebBlocks = false;
const blobs = res.result.reduce((acc, blockInput) => {
hasPostDenebBlocks ||= blockInput.type === BlockInputType.availableData;
return hasPostDenebBlocks
? acc + (blockInput.type === BlockInputType.availableData ? blockInput.blockData.blobs.length : 0)
: 0;
}, 0);
const downloadInfo = { blocks: res.result.length };
if (hasPostDenebBlocks) {
Object.assign(downloadInfo, { blobs });
}
this.logger.debug("Downloaded batch", {
id: this.logId,
...batch.getMetadata(),
...downloadInfo,
peer: prettyPrintPeerIdStr(peer),
});
this.triggerBatchProcessor();
}
else {
this.logger.verbose("Batch download error", { id: this.logId, ...batch.getMetadata(), peer: prettyPrintPeerIdStr(peer) }, res.err);
batch.downloadingError(); // Throws after MAX_DOWNLOAD_ATTEMPTS
}
// Preemptively request more blocks from peers whilst we process current blocks
this.triggerBatchDownloader();
}
catch (e) {
// bubble the error up to the main async iterable loop
this.batchProcessor.end(e);
}
// Preemptively request more blocks from peers whilst we process current blocks
this.triggerBatchDownloader();
}
/**
* Sends `batch` to the processor. Note: batch may be empty
*/
async processBatch(batch) {
const blocks = batch.startProcessing();
// wrapError ensures to never call both batch success() and batch error()
const res = await wrapError(this.processChainSegment(blocks, this.syncType));
if (!res.err) {
batch.processingSuccess();
// If the processed batch is not empty, validate previous AwaitingValidation blocks.
if (blocks.length > 0) {
this.advanceChain(batch.startEpoch);
}
// Potentially process next AwaitingProcessing batch
this.triggerBatchProcessor();
}
else {
this.logger.verbose("Batch process error", { id: this.logId, ...batch.getMetadata() }, res.err);
batch.processingError(res.err); // Throws after MAX_BATCH_PROCESSING_ATTEMPTS
// At least one block was successfully verified and imported, so we can be sure all
// previous batches are valid and we only need to download the current failed batch.
// TODO: Disabled for now
// if (res.err instanceof ChainSegmentError && res.err.importedBlocks > 0) {
// this.advanceChain(batch.startEpoch);
// }
// The current batch could not be processed, so either this or previous batches are invalid.
// All previous batches (AwaitingValidation) are potentially faulty and marked for retry.
// Progress will be drop back to `this.startEpoch`
for (const pendingBatch of this.batches.values()) {
if (pendingBatch.startEpoch < batch.startEpoch) {
this.logger.verbose("Batch validation error", { id: this.logId, ...pendingBatch.getMetadata() });
pendingBatch.validationError(res.err); // Throws after MAX_BATCH_PROCESSING_ATTEMPTS
}
}
}
// A batch is no longer in Processing status, queue has an empty spot to download next batch
this.triggerBatchDownloader();
}
/**
* Drops any batches previous to `newLatestValidatedEpoch` and updates the chain boundaries
*/
advanceChain(newLastEpochWithProcessBlocks) {
// make sure this epoch produces an advancement
if (newLastEpochWithProcessBlocks <= this.lastEpochWithProcessBlocks) {
return;
}
for (const [batchKey, batch] of this.batches.entries()) {
if (batch.startEpoch < newLastEpochWithProcessBlocks) {
this.batches.delete(batchKey);
this.validatedEpochs += EPOCHS_PER_BATCH;
// The last batch attempt is right, all others are wrong. Penalize other peers
const attemptOk = batch.validationSuccess();
for (const attempt of batch.failedProcessingAttempts) {
if (attempt.hash !== attemptOk.hash) {
if (attemptOk.peer === attempt.peer.toString()) {
// The same peer corrected its previous attempt
this.reportPeer(attempt.peer, PeerAction.MidToleranceError, "SyncChainInvalidBatchSelf");
}
else {
// A different peer sent an bad batch
this.reportPeer(attempt.peer, PeerAction.LowToleranceError, "SyncChainInvalidBatchOther");
}
}
}
}
}
this.lastEpochWithProcessBlocks = newLastEpochWithProcessBlocks;
}
}
/**
* Enforces that a report peer action is defined for all BatchErrorCode exhaustively.
* If peer should not be downscored, returns null.
*/
export function shouldReportPeerOnBatchError(code) {
switch (code) {
// A batch could not be processed after max retry limit. It's likely that all peers
// in this chain are sending invalid batches repeatedly so are either malicious or faulty.
// We drop the chain and report all peers.
// There are some edge cases with forks that could cause this situation, but it's unlikely.
case BatchErrorCode.MAX_PROCESSING_ATTEMPTS:
return { action: PeerAction.LowToleranceError, reason: "SyncChainMaxProcessingAttempts" };
// TODO: Should peers be reported for MAX_DOWNLOAD_ATTEMPTS?
case BatchErrorCode.WRONG_STATUS:
case BatchErrorCode.MAX_DOWNLOAD_ATTEMPTS:
case BatchErrorCode.MAX_EXECUTION_ENGINE_ERROR_ATTEMPTS:
return null;
}
}
//# sourceMappingURL=chain.js.map