@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
406 lines (366 loc) • 15.2 kB
text/typescript
import {ChainForkConfig} from "@lodestar/config";
import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice";
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {
DataAvailabilityStatus,
ExecutionPayloadStatus,
IBeaconStateView,
StateHashTreeRootSource,
computeEpochAtSlot,
computeStartSlotAtEpoch,
} from "@lodestar/state-transition";
import {BeaconBlock, RootHex, SignedBeaconBlock, Slot} from "@lodestar/types";
import {Logger, fromHex, toRootHex} from "@lodestar/utils";
import {IBeaconDb} from "../../db/index.js";
import {Metrics} from "../../metrics/index.js";
import {nextEventLoop} from "../../util/eventLoop.js";
import {getCheckpointFromState} from "../blocks/utils/checkpoint.js";
import {ChainEvent, ChainEventEmitter} from "../emitter.js";
import {SeenBlockInput} from "../seenCache/seenGossipBlockInput.js";
import {BlockStateCache, CheckpointStateCache} from "../stateCache/types.js";
import {ValidatorMonitor} from "../validatorMonitor.js";
import {RegenError, RegenErrorCode} from "./errors.js";
import {IStateRegeneratorInternal, RegenCaller, StateRegenerationOpts} from "./interface.js";
export type RegenModules = {
db: IBeaconDb;
forkChoice: IForkChoice;
blockStateCache: BlockStateCache;
checkpointStateCache: CheckpointStateCache;
seenBlockInputCache: SeenBlockInput;
config: ChainForkConfig;
emitter: ChainEventEmitter;
logger: Logger;
metrics: Metrics | null;
validatorMonitor: ValidatorMonitor | null;
};
/**
* 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 implements IStateRegeneratorInternal {
constructor(private readonly modules: RegenModules) {}
/**
* 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: BeaconBlock,
opts: StateRegenerationOpts,
regenCaller: RegenCaller
): Promise<IBeaconStateView> {
const parentRoot = toRootHex(block.parentRoot);
const parentBlock = this.modules.forkChoice.getBlockHexDefaultStatus(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.getBlockSlotState(parentBlock, block.slot, opts, regenCaller, allowDiskReload);
}
// Otherwise, get the state normally.
return this.getState(parentBlock.stateRoot, 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(
block: ProtoBlock,
slot: Slot,
opts: StateRegenerationOpts,
regenCaller: RegenCaller,
allowDiskReload = false
): Promise<IBeaconStateView> {
if (slot < block.slot) {
throw new RegenError({
code: RegenErrorCode.SLOT_BEFORE_BLOCK_SLOT,
slot,
blockSlot: block.slot,
});
}
const {blockRoot} = block;
const {checkpointStateCache} = this.modules;
const epoch = computeEpochAtSlot(slot);
const latestCheckpointStateCtx = allowDiskReload
? await checkpointStateCache.getOrReloadLatest(blockRoot, epoch)
: checkpointStateCache.getLatest(blockRoot, epoch);
// 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, 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: RootHex,
caller: RegenCaller,
// internal option, don't want to expose to external caller
allowDiskReload = false
): Promise<IBeaconStateView> {
// Trivial case, state at stateRoot is already cached
const cachedStateCtx = this.modules.blockStateCache.get(stateRoot);
if (cachedStateCtx) {
return cachedStateCtx;
}
// 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: IBeaconStateView | null = 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, block.payloadStatus)) {
state = this.modules.blockStateCache.get(b.stateRoot);
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)
: checkpointStateCache.getLatest(b.blockRoot, epoch);
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<Slot>(blockCount);
const blockPromises = new Array<Promise<SignedBeaconBlock | null>>(blockCount);
const protoBlocksAsc = blocksToReplay.reverse();
for (const [i, protoBlock] of protoBlocksAsc.entries()) {
replaySlots[i] = protoBlock.slot;
const blockInput = this.modules.seenBlockInputCache.get(protoBlock.blockRoot);
blockPromises[i] = blockInput?.hasBlock()
? Promise.resolve(blockInput.getBlock())
: 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<RootHex, SignedBeaconBlock>();
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.
// stateTransition() does the clone() inside, transfer cache to make the regen faster
state = state.stateTransition(
block,
{
// Replay previously imported blocks, assume valid and available
executionPayloadStatus: ExecutionPayloadStatus.valid,
dataAvailabilityStatus: DataAvailabilityStatus.Available,
verifyStateRoot: false,
verifyProposer: false,
verifySignatures: false,
dontTransferCache: 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 as Error,
});
}
}
stateTransitionTimer?.();
this.modules.logger.debug("Replayed blocks to get state", {...logCtx, stateSlot: state.slot});
return state;
}
private findFirstStateBlock(stateRoot: RootHex): ProtoBlock {
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: {
checkpointStateCache: CheckpointStateCache;
metrics: Metrics | null;
validatorMonitor: ValidatorMonitor | null;
emitter: ChainEventEmitter;
config: ChainForkConfig;
logger: Logger;
},
preState: IBeaconStateView,
slot: Slot,
regenCaller: RegenCaller,
opts: StateRegenerationOpts
): Promise<IBeaconStateView> {
let postState = await processSlotsToNearestCheckpoint(modules, preState, slot, regenCaller, opts);
if (postState.slot < slot) {
postState = postState.processSlots(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: {
checkpointStateCache: CheckpointStateCache;
metrics: Metrics | null;
validatorMonitor: ValidatorMonitor | null;
emitter: ChainEventEmitter | null;
config: ChainForkConfig;
logger: Logger | null;
},
preState: IBeaconStateView,
slot: Slot,
regenCaller: RegenCaller,
opts: StateRegenerationOpts
): Promise<IBeaconStateView> {
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 = postState.processSlots(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 state ever
emitter?.emit(ChainEvent.checkpoint, cp, checkpointState);
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 as Error
);
}
}
count++;
// this avoids keeping our node busy processing blocks
await nextEventLoop();
}
return postState;
}