UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

306 lines • 15.2 kB
import { SLOTS_PER_EPOCH } from "@lodestar/params"; import { DataAvailabilityStatus, ExecutionPayloadStatus, StateHashTreeRootSource, computeEpochAtSlot, computeStartSlotAtEpoch, processSlots, stateTransition, } from "@lodestar/state-transition"; import { fromHex, toRootHex } from "@lodestar/utils"; import { nextEventLoop } from "../../util/eventLoop.js"; import { getCheckpointFromState } from "../blocks/utils/checkpoint.js"; import { ChainEvent } from "../emitter.js"; import { RegenError, RegenErrorCode } from "./errors.js"; /** * Regenerates states that have already been processed by the fork choice * Since Feb 2024, we support reloading checkpoint state from disk via allowDiskReload flag. Due to its performance impact * this flag is only set to true in this case: * - getPreState: this is for block processing, it's important to reload state in unfinality time * - updateHeadState: rarely happen, but it's important to make sure we always can regen head state */ export class StateRegenerator { constructor(modules) { this.modules = modules; } /** * Get the state to run with `block`. May be: * - If parent is in same epoch -> Exact state at `block.parentRoot` * - If parent is in prev epoch -> State after `block.parentRoot` dialed forward through epoch transition * - reload state if needed in this flow */ async getPreState(block, opts, regenCaller) { const parentBlock = this.modules.forkChoice.getBlock(block.parentRoot); if (!parentBlock) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_FORKCHOICE, blockRoot: block.parentRoot, }); } const parentEpoch = computeEpochAtSlot(parentBlock.slot); const blockEpoch = computeEpochAtSlot(block.slot); const allowDiskReload = true; // This may save us at least one epoch transition. // If the requested state crosses an epoch boundary // then we may use the checkpoint state before the block // We may have the checkpoint state with parent root inside the checkpoint state cache // through gossip validation. if (parentEpoch < blockEpoch) { return this.getCheckpointState({ root: block.parentRoot, epoch: blockEpoch }, opts, regenCaller, allowDiskReload); } // Otherwise, get the state normally. return this.getState(parentBlock.stateRoot, regenCaller, opts, allowDiskReload); } /** * Get state after block `cp.root` dialed forward to first slot of `cp.epoch` */ async getCheckpointState(cp, opts, regenCaller, allowDiskReload = false) { const checkpointStartSlot = computeStartSlotAtEpoch(cp.epoch); return this.getBlockSlotState(toRootHex(cp.root), checkpointStartSlot, opts, regenCaller, allowDiskReload); } /** * Get state after block `blockRoot` dialed forward to `slot` * - allowDiskReload should be used with care, as it will cause the state to be reloaded from disk */ async getBlockSlotState(blockRoot, slot, opts, regenCaller, allowDiskReload = false) { const block = this.modules.forkChoice.getBlockHex(blockRoot); if (!block) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_FORKCHOICE, blockRoot, }); } if (slot < block.slot) { throw new RegenError({ code: RegenErrorCode.SLOT_BEFORE_BLOCK_SLOT, slot, blockSlot: block.slot, }); } const { checkpointStateCache } = this.modules; const epoch = computeEpochAtSlot(slot); const latestCheckpointStateCtx = allowDiskReload ? await checkpointStateCache.getOrReloadLatest(blockRoot, epoch, opts) : checkpointStateCache.getLatest(blockRoot, epoch, opts); // If a checkpoint state exists with the given checkpoint root, it either is in requested epoch // or needs to have empty slots processed until the requested epoch if (latestCheckpointStateCtx) { return processSlotsByCheckpoint(this.modules, latestCheckpointStateCtx, slot, regenCaller, opts); } // Otherwise, use the fork choice to get the stateRoot from block at the checkpoint root // regenerate that state, // then process empty slots until the requested epoch const blockStateCtx = await this.getState(block.stateRoot, regenCaller, opts, allowDiskReload); return processSlotsByCheckpoint(this.modules, blockStateCtx, slot, regenCaller, opts); } /** * Get state by exact root. If not in cache directly, requires finding the block that references the state from the * forkchoice and replaying blocks to get to it. * - allowDiskReload should be used with care, as it will cause the state to be reloaded from disk */ async getState(stateRoot, caller, opts, // internal option, don't want to expose to external caller allowDiskReload = false) { // Trivial case, state at stateRoot is already cached const cachedStateCtx = this.modules.blockStateCache.get(stateRoot, opts); if (cachedStateCtx) { return cachedStateCtx; } // in block gossip validation (getPreState() call), dontTransferCache is specified as true because we only want to transfer cache in verifyBlocksStateTransitionOnly() // but here we want to process blocks as fast as possible so force to transfer cache in this case if (opts && allowDiskReload) { // if there is no `opts` specified, it already means "false" opts.dontTransferCache = false; } // Otherwise we have to use the fork choice to traverse backwards, block by block, // searching the state caches // then replay blocks forward to the desired stateRoot const block = this.findFirstStateBlock(stateRoot); // blocks to replay, ordered highest to lowest // gets reversed when replayed const blocksToReplay = [block]; let state = null; const { checkpointStateCache } = this.modules; const getSeedStateTimer = this.modules.metrics?.regenGetState.getSeedState.startTimer({ caller }); // iterateAncestorBlocks only returns ancestor blocks, not the block itself for (const b of this.modules.forkChoice.iterateAncestorBlocks(block.blockRoot)) { state = this.modules.blockStateCache.get(b.stateRoot, opts); if (state) { break; } const lastBlockToReplay = blocksToReplay.at(-1); if (!lastBlockToReplay) continue; const epoch = computeEpochAtSlot(lastBlockToReplay.slot - 1); state = allowDiskReload ? await checkpointStateCache.getOrReloadLatest(b.blockRoot, epoch, opts) : checkpointStateCache.getLatest(b.blockRoot, epoch, opts); if (state) { break; } blocksToReplay.push(b); } getSeedStateTimer?.(); if (state === null) { throw new RegenError({ code: RegenErrorCode.NO_SEED_STATE, }); } const blockCount = blocksToReplay.length; const MAX_EPOCH_TO_PROCESS = 5; if (blockCount > MAX_EPOCH_TO_PROCESS * SLOTS_PER_EPOCH) { throw new RegenError({ code: RegenErrorCode.TOO_MANY_BLOCK_PROCESSED, stateRoot, }); } this.modules.metrics?.regenGetState.blockCount.observe({ caller }, blockCount); const replaySlots = new Array(blockCount); const blockPromises = new Array(blockCount); const protoBlocksAsc = blocksToReplay.reverse(); for (const [i, protoBlock] of protoBlocksAsc.entries()) { replaySlots[i] = protoBlock.slot; blockPromises[i] = this.modules.db.block.get(fromHex(protoBlock.blockRoot)); } const logCtx = { stateRoot, caller, replaySlots: replaySlots.join(",") }; this.modules.logger.debug("Replaying blocks to get state", logCtx); const loadBlocksTimer = this.modules.metrics?.regenGetState.loadBlocks.startTimer({ caller }); const blockOrNulls = await Promise.all(blockPromises); loadBlocksTimer?.(); const blocksByRoot = new Map(); for (const [i, blockOrNull] of blockOrNulls.entries()) { // checking early here helps prevent unneccessary state transition below if (blockOrNull === null) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_DB, blockRoot: protoBlocksAsc[i].blockRoot, }); } blocksByRoot.set(protoBlocksAsc[i].blockRoot, blockOrNull); } const stateTransitionTimer = this.modules.metrics?.regenGetState.stateTransition.startTimer({ caller }); for (const b of protoBlocksAsc) { const block = blocksByRoot.get(b.blockRoot); // just to make compiler happy, we checked in the above for loop already if (block === undefined) { throw new RegenError({ code: RegenErrorCode.BLOCK_NOT_IN_DB, blockRoot: b.blockRoot, }); } try { // Only advances state trusting block's signture and hashes. // We are only running the state transition to get a specific state's data. state = stateTransition(state, block, { // Replay previously imported blocks, assume valid and available executionPayloadStatus: ExecutionPayloadStatus.valid, dataAvailabilityStatus: DataAvailabilityStatus.Available, verifyStateRoot: false, verifyProposer: false, verifySignatures: false, }, this.modules); const hashTreeRootTimer = this.modules.metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.regenState, }); const stateRoot = toRootHex(state.hashTreeRoot()); hashTreeRootTimer?.(); if (b.stateRoot !== stateRoot) { throw new RegenError({ slot: b.slot, code: RegenErrorCode.INVALID_STATE_ROOT, actual: stateRoot, expected: b.stateRoot, }); } if (allowDiskReload) { // also with allowDiskReload flag, we "reload" it to the state cache too this.modules.blockStateCache.add(state); } } catch (e) { throw new RegenError({ code: RegenErrorCode.STATE_TRANSITION_ERROR, error: e, }); } } stateTransitionTimer?.(); this.modules.logger.debug("Replayed blocks to get state", { ...logCtx, stateSlot: state.slot }); return state; } findFirstStateBlock(stateRoot) { for (const block of this.modules.forkChoice.forwarditerateAncestorBlocks()) { if (block.stateRoot === stateRoot) { return block; } } throw new RegenError({ code: RegenErrorCode.STATE_NOT_IN_FORKCHOICE, stateRoot, }); } } /** * Starting at `state.slot`, * process slots forward towards `slot`, * emitting "checkpoint" events after every epoch processed. */ async function processSlotsByCheckpoint(modules, preState, slot, regenCaller, opts) { let postState = await processSlotsToNearestCheckpoint(modules, preState, slot, regenCaller, opts); if (postState.slot < slot) { postState = processSlots(postState, slot, opts, modules); } return postState; } /** * Starting at `state.slot`, * process slots forward towards `slot`, * emitting "checkpoint" events after every epoch processed. * * Stops processing after no more full epochs can be processed. */ export async function processSlotsToNearestCheckpoint(modules, preState, slot, regenCaller, opts) { const preSlot = preState.slot; const postSlot = slot; const preEpoch = computeEpochAtSlot(preSlot); let postState = preState; const { checkpointStateCache, emitter, metrics, logger } = modules; let count = 0; for (let nextEpochSlot = computeStartSlotAtEpoch(preEpoch + 1); nextEpochSlot <= postSlot; nextEpochSlot += SLOTS_PER_EPOCH) { logger?.verbose("Processing slots over epochs", { slot: postState.slot, nextEpochSlot, postSlot, caller: regenCaller, }); // processSlots calls .clone() before mutating postState = processSlots(postState, nextEpochSlot, opts, modules); metrics?.epochTransitionByCaller.inc({ caller: regenCaller }); // this is usually added when we prepare for next slot or validate gossip block // then when we process the 1st block of epoch, we don't have to do state transition again // This adds Previous Root Checkpoint State to the checkpoint state cache // This may becomes the "official" checkpoint state if the 1st block of epoch is skipped const checkpointState = postState; const cp = getCheckpointFromState(checkpointState); checkpointStateCache.add(cp, checkpointState); // consumers should not mutate or get the transfered cache emitter?.emit(ChainEvent.checkpoint, cp, checkpointState.clone(true)); if (count >= 1) { // in normal condition, we only process 1 epoch so never reach this // in that case, we want to prune state at the last 1/3 slot of slot 0 of the next epoch after importing the 1st block of epoch // in non-finality time, we may process a lot of epochs so need to prune the cache to keep the node healthy // this happened to holesky on Feb 2025, see https://github.com/ChainSafe/lodestar/issues/7495#issuecomment-2680800898 // cannot use getBlockRootAtSlot() because nextEpochSlot = postState const latestBlockHex = toRootHex(cp.root); try { const persistCount = await checkpointStateCache.processState(latestBlockHex, checkpointState); logger?.verbose("pruning checkpointStateCache during processSlotsToNearestCheckpoint", { root: latestBlockHex, epoch: cp.epoch, persistCount, }); } catch (e) { logger?.debug("CheckpointStateCache failed to process checkpoint state", { root: latestBlockHex, epoch: cp.epoch }, e); } } count++; // this avoids keeping our node busy processing blocks await nextEventLoop(); } return postState; } //# sourceMappingURL=regen.js.map