UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

876 lines (802 loc) 38.8 kB
import {routes} from "@lodestar/api"; import {BeaconConfig} from "@lodestar/config"; import {IBeaconStateView, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, phase0} from "@lodestar/types"; import {Logger, MapDef, fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {AllocSource, BufferPool, BufferWithKey} from "../../util/bufferPool.js"; import {IClock} from "../../util/clock.js"; import {serializeState} from "../serializeState.js"; import {CPStateDatastore, DatastoreKey} from "./datastore/index.js"; import {MapTracker} from "./mapMetrics.js"; import {BlockStateCache, CacheItemType, CheckpointHex, CheckpointStateCache} from "./types.js"; export type PersistentCheckpointStateCacheOpts = { /** Keep max n state epochs in memory, persist the rest to disk */ maxCPStateEpochsInMemory?: number; /** Keep max n state epochs on disk */ maxCPStateEpochsOnDisk?: number; }; type PersistentCheckpointStateCacheModules = { config: BeaconConfig; metrics?: Metrics | null; logger: Logger; clock?: IClock | null; signal?: AbortSignal; datastore: CPStateDatastore; blockStateCache: BlockStateCache; bufferPool?: BufferPool; }; /** checkpoint serialized as a string */ type CacheKey = string; type InMemoryCacheItem = { type: CacheItemType.inMemory; state: IBeaconStateView; // if a cp state is reloaded from disk, it'll keep track of persistedKey to allow us to remove it from disk later // it also helps not to persist it again persistedKey?: DatastoreKey; }; type PersistedCacheItem = { type: CacheItemType.persisted; value: DatastoreKey; }; type CacheItem = InMemoryCacheItem | PersistedCacheItem; type LoadedStateBytesData = {persistedKey: DatastoreKey; stateBytes: Uint8Array}; /** * Before n-historical states, lodestar keeps all checkpoint states since finalized * Since Sep 2024, lodestar stores 3 most recent checkpoint states in memory and the rest on disk. The finalized state * may not be available in memory, and stay on disk instead. */ export const DEFAULT_MAX_CP_STATE_EPOCHS_IN_MEMORY = 3; /** * By default we don't prune any persistent checkpoint states as it's not safe to delete them during * long non-finality as we don't know the state of the chain and there could be a deep (hundreds of epochs) reorg * if there two competing chains with similar weight but we wouldn't have a close enough state to pivot to this chain * and instead require a resync from last finalized checkpoint state which could be very far in the past. */ export const DEFAULT_MAX_CP_STATE_ON_DISK = Infinity; // TODO GLOAS: re-evaluate this timing const PROCESS_CHECKPOINT_STATES_BPS = 6667; /** * An implementation of CheckpointStateCache that keep up to n epoch checkpoint states in memory and persist the rest to disk * - If it's more than `maxEpochsInMemory` epochs old, it will persist n last epochs to disk based on the view of the block * - Once a chain gets finalized we'll prune all states from memory and disk for epochs < finalizedEpoch * - In get*() apis if shouldReload is true, it will reload from disk. The reload() api is expensive and should only be called in some important flows: * - Get state for block processing * - updateHeadState * - as with any cache, the state could be evicted from memory at any time, so we should always check if the state is in memory or not * - Each time we process a state, we only persist exactly 1 checkpoint state per epoch based on the view of block and prune all others. The persisted * checkpoint state could be finalized and used later in archive task, it's also used to regen states. * - When we process multiple states in the same epoch, we could persist different checkpoint states of the same epoch because each block could have its * own view. See unit test of this file `packages/beacon-node/test/unit/chain/stateCache/persistentCheckpointsCache.test.ts` for more details. * * The below diagram shows Previous Root Checkpoint State is persisted for epoch (n-2) and Current Root Checkpoint State is persisted for epoch (n-1) * while at epoch (n) and (n+1) we have both of them in memory * * ╔════════════════════════════════════╗═══════════════╗ * ║ persisted to db or fs ║ in memory ║ * ║ reload if needed ║ ║ * ║ -----------------------------------║---------------║ * ║ epoch: (n-2) (n-1) ║ n (n+1) ║ * ║ |-------|-------|----║--|-------|----║ * ║ ^ ^ ║ ^ ^ ║ * ║ ║ ^ ^ ║ * ╚════════════════════════════════════╝═══════════════╝ * * The "in memory" checkpoint states are similar to the old implementation: we have both Previous Root Checkpoint State and Current Root Checkpoint State per epoch. * However in the "persisted to db or fs" part * - if there is no reorg, we only store 1 checkpoint state per epoch, the one that could potentially be justified/finalized later based on the view of the state * - if there is reorg, we may store >=2 checkpoint states per epoch, including any checkpoints with unknown roots to the processed state * - the goal is to make sure we can regen any states later if needed, and we have the checkpoint state that could be justified/finalized later */ export class PersistentCheckpointStateCache implements CheckpointStateCache { private readonly cache: MapTracker<CacheKey, CacheItem>; /** Epoch -> Set<blockRoot> */ private readonly epochIndex = new MapDef<Epoch, Set<RootHex>>(() => new Set<string>()); private readonly config: BeaconConfig; private readonly metrics: Metrics | null | undefined; private readonly logger: Logger; private readonly clock: IClock | null | undefined; private readonly signal: AbortSignal | undefined; private preComputedCheckpoint: string | null = null; private preComputedCheckpointHits: number | null = null; private readonly maxEpochsInMemory: number; private readonly maxEpochsOnDisk: number; private readonly datastore: CPStateDatastore; private readonly blockStateCache: BlockStateCache; private readonly bufferPool?: BufferPool; constructor( { config, metrics, logger, clock, signal, datastore, blockStateCache, bufferPool, }: PersistentCheckpointStateCacheModules, opts: PersistentCheckpointStateCacheOpts ) { this.cache = new MapTracker(metrics?.cpStateCache); this.config = config; if (metrics) { this.metrics = metrics; metrics.cpStateCache.size.addCollect(() => { let persistCount = 0; let inMemoryCount = 0; const memoryEpochs = new Set<Epoch>(); const persistentEpochs = new Set<Epoch>(); for (const [key, cacheItem] of this.cache.entries()) { const {epoch} = fromCacheKey(key); if (isPersistedCacheItem(cacheItem)) { persistCount++; persistentEpochs.add(epoch); } else { inMemoryCount++; memoryEpochs.add(epoch); } } metrics.cpStateCache.size.set({type: CacheItemType.persisted}, persistCount); metrics.cpStateCache.size.set({type: CacheItemType.inMemory}, inMemoryCount); metrics.cpStateCache.epochSize.set({type: CacheItemType.persisted}, persistentEpochs.size); metrics.cpStateCache.epochSize.set({type: CacheItemType.inMemory}, memoryEpochs.size); }); } this.logger = logger; this.clock = clock; this.signal = signal; if (opts.maxCPStateEpochsInMemory !== undefined && opts.maxCPStateEpochsInMemory < 0) { throw new Error("maxEpochsInMemory must be >= 0"); } if (opts.maxCPStateEpochsOnDisk !== undefined && opts.maxCPStateEpochsOnDisk < 0) { throw new Error("maxCPStateEpochsOnDisk must be >= 0"); } this.maxEpochsInMemory = opts.maxCPStateEpochsInMemory ?? DEFAULT_MAX_CP_STATE_EPOCHS_IN_MEMORY; this.maxEpochsOnDisk = opts.maxCPStateEpochsOnDisk ?? DEFAULT_MAX_CP_STATE_ON_DISK; // Specify different datastore for testing this.datastore = datastore; this.blockStateCache = blockStateCache; this.bufferPool = bufferPool; } /** * Reload checkpoint state keys from the last run. */ async init(): Promise<void> { if (this.datastore?.init) { await this.datastore.init(); } const persistedKeys = await this.datastore.readKeys(); // all checkpoint states from the last run are not trusted, remove them // otherwise if we have a bad checkpoint state from the last run, the node get stucked // this was found during mekong devnet, see https://github.com/ChainSafe/lodestar/pull/7255 await Promise.all(persistedKeys.map((key) => this.datastore.remove(key))); this.logger.info("Removed persisted checkpoint states from the last run", { count: persistedKeys.length, maxEpochsInMemory: this.maxEpochsInMemory, }); } /** * Get a state from cache, it may reload from disk. * This is an expensive api, should only be called in some important flows: * - Validate a gossip block * - Get block for processing * - Regen head state */ async getOrReload(cp: CheckpointHex): Promise<IBeaconStateView | null> { const stateOrStateBytesData = await this.getStateOrLoadDb(cp); if (stateOrStateBytesData === null || isBeaconStateView(stateOrStateBytesData)) { return stateOrStateBytesData ?? null; } const {persistedKey, stateBytes} = stateOrStateBytesData; const logMeta = {persistedKey: toHex(persistedKey)}; this.logger.debug("Reload: read state successful", logMeta); this.metrics?.cpStateCache.stateReloadSecFromSlot.observe( this.clock?.secFromSlot(this.clock?.currentSlot ?? 0) ?? 0 ); const seedState = this.findSeedStateToReload(cp); this.metrics?.cpStateCache.stateReloadEpochDiff.observe(Math.abs(seedState.epoch - cp.epoch)); this.logger.debug("Reload: found seed state", {...logMeta, seedSlot: seedState.slot}); try { // 80% of validators serialization time comes from memory allocation, this is to avoid it const sszTimer = this.metrics?.cpStateCache.stateReloadValidatorsSerializeDuration.startTimer(); // automatically free the buffer pool after this scope using validatorsBytesWithKey = this.serializeStateValidators(seedState); let validatorsBytes = validatorsBytesWithKey?.buffer; if (validatorsBytes == null) { // fallback logic in case we can't use the buffer pool this.metrics?.cpStateCache.stateReloadValidatorsSerializeAllocCount.inc(); validatorsBytes = seedState.serializeValidators(); } sszTimer?.(); const timer = this.metrics?.cpStateCache.stateReloadDuration.startTimer(); // preload validators and balances for faster state transition const newCachedState = seedState.loadOtherState(stateBytes, validatorsBytes, { preloadValidatorsAndBalances: true, }); // hashTreeRoot() calls the commit() inside // there is no modification inside the state, it's just that we want to compute and cache all roots const stateRoot = toRootHex(newCachedState.hashTreeRoot()); timer?.(); this.logger.debug("Reload: cached state load successful", { ...logMeta, stateSlot: newCachedState.slot, stateRoot, seedSlot: seedState.slot, }); // only remove persisted state once we reload successfully const cpKey = toCacheKey(cp); this.cache.set(cpKey, {type: CacheItemType.inMemory, state: newCachedState, persistedKey}); this.epochIndex.getOrDefault(cp.epoch).add(cp.rootHex); // don't prune from memory here, call it at the last 1/3 of slot 0 of an epoch return newCachedState; } catch (e) { this.logger.debug("Reload: error loading cached state", logMeta, e as Error); return null; } } /** * Return either state or state bytes loaded from db. */ async getStateOrBytes(cp: CheckpointHex): Promise<IBeaconStateView | Uint8Array | null> { const stateOrLoadedState = await this.getStateOrLoadDb(cp); if (stateOrLoadedState === null || isBeaconStateView(stateOrLoadedState)) { return stateOrLoadedState; } return stateOrLoadedState.stateBytes; } /** * Return either state or state bytes with persisted key loaded from db. */ async getStateOrLoadDb(cp: CheckpointHex): Promise<IBeaconStateView | LoadedStateBytesData | null> { const cpKey = toCacheKey(cp); const inMemoryState = this.get(cpKey); if (inMemoryState) { return inMemoryState; } const cacheItem = this.cache.get(cpKey); if (cacheItem === undefined) { return null; } if (isInMemoryCacheItem(cacheItem)) { // should not happen, in-memory state is handled above throw new Error("Expected persistent key"); } const persistedKey = cacheItem.value; const dbReadTimer = this.metrics?.cpStateCache.stateReloadDbReadTime.startTimer(); const stateBytes = await this.datastore.read(persistedKey); dbReadTimer?.(); if (stateBytes === null) { return null; } return {persistedKey, stateBytes}; } /** * Similar to get() api without reloading from disk */ get(cpOrKey: CheckpointHex | CacheKey): IBeaconStateView | null { this.metrics?.cpStateCache.lookups.inc(); const cpKey = typeof cpOrKey === "string" ? cpOrKey : toCacheKey(cpOrKey); const cacheItem = this.cache.get(cpKey); if (cacheItem === undefined) { return null; } this.metrics?.cpStateCache.hits.inc(); if (cpKey === this.preComputedCheckpoint) { this.preComputedCheckpointHits = (this.preComputedCheckpointHits ?? 0) + 1; } if (isInMemoryCacheItem(cacheItem)) { const {state} = cacheItem; this.metrics?.cpStateCache.stateClonedCount.observe(state.clonedCount); return state; } return null; } /** * Add a state of a checkpoint to this cache, prune from memory if necessary. */ add(cp: phase0.Checkpoint, state: IBeaconStateView): void { const cpHex = toCheckpointHex(cp); const key = toCacheKey(cpHex); const cacheItem = this.cache.get(key); this.metrics?.cpStateCache.adds.inc(); if (cacheItem !== undefined && isPersistedCacheItem(cacheItem)) { const persistedKey = cacheItem.value; // was persisted to disk, set back to memory this.cache.set(key, {type: CacheItemType.inMemory, state, persistedKey}); this.logger.verbose("Added checkpoint state to memory but a persisted key existed", { epoch: cp.epoch, rootHex: cpHex.rootHex, persistedKey: toHex(persistedKey), }); } else { this.cache.set(key, {type: CacheItemType.inMemory, state}); this.logger.verbose("Added checkpoint state to memory", {epoch: cp.epoch, rootHex: cpHex.rootHex}); } this.epochIndex.getOrDefault(cp.epoch).add(cpHex.rootHex); this.prunePersistedStates(); } /** * Searches in-memory state for the latest cached state with a `root` without reload, starting with `epoch` and descending */ getLatest(rootHex: RootHex, maxEpoch: Epoch): IBeaconStateView | null { // sort epochs in descending order, only consider epochs lte `epoch` const epochs = Array.from(this.epochIndex.keys()) .sort((a, b) => b - a) .filter((e) => e <= maxEpoch); for (const epoch of epochs) { if (this.epochIndex.get(epoch)?.has(rootHex)) { const inMemoryClonedState = this.get({rootHex, epoch}); if (inMemoryClonedState) { return inMemoryClonedState; } } } return null; } /** * Searches state for the latest cached state with a `root`, reload if needed, starting with `epoch` and descending * This is expensive api, should only be called in some important flows: * - Validate a gossip block * - Get block for processing * - Regen head state */ async getOrReloadLatest(rootHex: RootHex, maxEpoch: Epoch): Promise<IBeaconStateView | null> { // sort epochs in descending order, only consider epochs lte `epoch` const epochs = Array.from(this.epochIndex.keys()) .sort((a, b) => b - a) .filter((e) => e <= maxEpoch); for (const epoch of epochs) { if (this.epochIndex.get(epoch)?.has(rootHex)) { try { const state = await this.getOrReload({rootHex, epoch}); if (state) { return state; } } catch (e) { this.logger.debug("Error get or reload state", {epoch, rootHex}, e as Error); } } } return null; } /** * Update the precomputed checkpoint and return the number of hits for the * previous one (if any). */ updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch): number | null { const previousHits = this.preComputedCheckpointHits; this.preComputedCheckpoint = toCacheKey({rootHex, epoch}); this.preComputedCheckpointHits = 0; return previousHits; } /** * This is just to conform to the old implementation */ prune(): void { // do nothing } /** * Prune all checkpoint states before the provided finalized epoch. */ pruneFinalized(finalizedEpoch: Epoch): void { for (const epoch of this.epochIndex.keys()) { if (epoch < finalizedEpoch) { this.deleteAllEpochItems(epoch).catch((e) => this.logger.debug("Error delete all epoch items", {epoch, finalizedEpoch}, e as Error) ); } } } /** * After processing a block, prune from memory based on the view of that block. * This is likely persist 1 state per epoch, at the last 1/3 of slot 0 of an epoch although it'll be called on every last 1/3 of slot. * Given the following block b was processed with b2, b1, b0 are ancestors in epoch (n-2), (n-1), n respectively * * epoch: (n-2) (n-1) n (n+1) * |-----------|-----------|-----------|-----------| * ^ ^ ^ ^ * | | | | * block chain: b2---------->b1--------->b0-->b * * After processing block b, if maxEpochsInMemory is: * - 2 then we'll persist {root: b2, epoch n-2} checkpoint state to disk * - 1 then we'll persist {root: b2, epoch n-2} and {root: b1, epoch n-1} checkpoint state to disk * - 0 then we'll persist {root: b2, epoch n-2} and {root: b1, epoch n-1} and {root: b0, epoch n} checkpoint state to disk * - if any old epochs checkpoint states are persisted, no need to do it again * * Note that for each epoch there could be multiple checkpoint states, usually 2, one for Previous Root Checkpoint State and one for Current Root Checkpoint State. * We normally only persist 1 checkpoint state per epoch, the one that could potentially be justified/finalized later based on the view of the block. * Other checkpoint states are pruned from memory. * * This design also covers the reorg scenario. Given block c in the same epoch n where c.slot > b.slot, c is not descendant of b, and c is built on top of c0 * instead of b0 (epoch (n - 1)) * * epoch: (n-2) (n-1) n (n+1) * |-----------|-----------|-----------|-----------| * ^ ^ ^ ^ ^ ^ * | | | | | | * block chain: b2---------->b1----->c0->b0-->b | * ║ | * ╚═══════════>c (reorg) * * After processing block c, if maxEpochsInMemory is: * - 0 then we'll persist {root: c0, epoch: n} checkpoint state to disk. Note that regen should populate {root: c0, epoch: n} checkpoint state before. * * epoch: (n-1) n (n+1) * |-------------------------------------------------------------|-------------------------------------------------------------| * ^ ^ ^ ^ * _______ | | | | * | | | | | | * | db |====== reload ======> {root: b1, epoch: n-1} cp state ======> c0 block state ======> {root: c0, epoch: n} cp state =====> c block state * |_______| * * * * - 1 then we'll persist {root: b1, epoch n-1} checkpoint state to disk. Note that at epoch n there is both {root: b0, epoch: n} and {root: c0, epoch: n} checkpoint states in memory * - 2 then we'll persist {root: b2, epoch n-2} checkpoint state to disk, there are also 2 checkpoint states in memory at epoch n, same to the above (maxEpochsInMemory=1) * * As of Mar 2024, it takes <=350ms to persist a holesky state on fast server */ async processState(blockRootHex: RootHex, state: IBeaconStateView): Promise<number> { let persistCount = 0; // it's important to sort the epochs in ascending order, in case of big reorg we always want to keep the most recent checkpoint states const sortedEpochs = Array.from(this.epochIndex.keys()).sort((a, b) => a - b); if (sortedEpochs.length <= this.maxEpochsInMemory) { return 0; } const blockSlot = state.slot; const processCPStatesTimeMs = this.config.getSlotComponentDurationMs(PROCESS_CHECKPOINT_STATES_BPS); // we always have clock in production, fallback value is only for test const msFromSlot = this.clock?.msFromSlot(blockSlot) ?? processCPStatesTimeMs; const msToProcessCPStates = processCPStatesTimeMs - msFromSlot; if (msToProcessCPStates > 0) { // At ~67% of slot is the most free time of every slot, take that chance to persist checkpoint states // normally it should only persist checkpoint states at ~67% of slot 0 of epoch await sleep(msToProcessCPStates, this.signal); } // at syncing time, it's critical to persist checkpoint states as soon as possible to avoid OOM during unfinality time // if node is synced this is not a hot time because block comes late, we'll likely miss attestation already, or the block is orphaned const persistEpochs = sortedEpochs.slice(0, sortedEpochs.length - this.maxEpochsInMemory); for (const lowestEpoch of persistEpochs) { try { // getBlockRootAtSlot() may fail, see https://github.com/ChainSafe/lodestar/issues/7495 if (state.slot < computeStartSlotAtEpoch(lowestEpoch)) { // there is no checkpoint states of epochs newer than this state break; } // usually there is only 0 or 1 epoch to persist in this loop persistCount += await this.processPastEpoch(blockRootHex, state, lowestEpoch); this.logger.verbose("Processed past epoch", {epoch: lowestEpoch, slot: blockSlot, root: blockRootHex}); } catch (e) { this.logger.debug( "Error processing past epoch", {epoch: lowestEpoch, slot: blockSlot, root: blockRootHex}, e as Error ); } } if (persistCount > 0) { this.logger.verbose("Persisted checkpoint states", { slot: blockSlot, root: blockRootHex, persistCount, persistEpochs: persistEpochs.length, }); } return persistCount; } /** * Find a seed state to reload the state of provided checkpoint. Based on the design of n-historical state: * * ╔════════════════════════════════════╗═══════════════╗ * ║ persisted to db or fs ║ in memory ║ * ║ reload if needed ║ ║ * ║ -----------------------------------║---------------║ * ║ epoch: (n-2) (n-1) ║ n (n+1) ║ * ║ |-------|-------|----║--|-------|----║ * ║ ^ ^ ║ ^ ^ ║ * ║ ║ ^ ^ ║ * ╚════════════════════════════════════╝═══════════════╝ * * we always reload an epoch in the past. We'll start with epoch n then (n+1) prioritizing ones with the same view of `reloadedCp`. * * Use seed state from the block cache if cannot find any seed states within this cache. */ findSeedStateToReload(reloadedCp: CheckpointHex): IBeaconStateView { const maxEpoch = Math.max(...Array.from(this.epochIndex.keys())); const reloadedCpSlot = computeStartSlotAtEpoch(reloadedCp.epoch); let firstState: IBeaconStateView | null = null; const logCtx = {reloadedCpEpoch: reloadedCp.epoch, reloadedCpRoot: reloadedCp.rootHex}; // no need to check epochs before `maxEpoch - this.maxEpochsInMemory + 1` before they are all persisted for (let epoch = maxEpoch - this.maxEpochsInMemory + 1; epoch <= maxEpoch; epoch++) { // if there's at least 1 state in memory in an epoch, just return the 1st one if (firstState !== null) { return firstState; } for (const rootHex of this.epochIndex.get(epoch) || []) { const cpKey = toCacheKey({rootHex, epoch}); const cacheItem = this.cache.get(cpKey); if (cacheItem === undefined) { continue; } if (isInMemoryCacheItem(cacheItem)) { const {state} = cacheItem; if (firstState === null) { firstState = state; } const cpLog = {cpEpoch: epoch, cpRoot: rootHex}; try { // amongst states of the same epoch, choose the one with the same view of reloadedCp if ( reloadedCpSlot < state.slot && toRootHex(state.getBlockRootAtSlot(reloadedCpSlot)) === reloadedCp.rootHex ) { this.logger.verbose("Reload: use checkpoint state as seed state", {...cpLog, ...logCtx}); return state; } } catch (e) { // getBlockRootAtSlot may throw error this.logger.debug("Error finding checkpoint state to reload", {...cpLog, ...logCtx}, e as Error); } } } } // fallback to using the default seed state from block state cache const seedBlockState = this.blockStateCache.getSeedState(); this.logger.verbose("Reload: use default block state as seed state", {stateSlot: seedBlockState.slot, ...logCtx}); return seedBlockState; } clear(): void { this.cache.clear(); this.epochIndex.clear(); } /** ONLY FOR DEBUGGING PURPOSES. For lodestar debug API */ dumpSummary(): routes.lodestar.StateCacheItem[] { return Array.from(this.cache.keys()).map((key) => { const cp = fromCacheKey(key); // TODO: add checkpoint key and persistent key to the summary return { slot: computeStartSlotAtEpoch(cp.epoch), root: cp.rootHex, reads: this.cache.readCount.get(key) ?? 0, lastRead: this.cache.lastRead.get(key) ?? 0, checkpointState: true, }; }); } getStates(): IterableIterator<IBeaconStateView> { const items = Array.from(this.cache.values()) .filter(isInMemoryCacheItem) .map((item) => item.state); return items.values(); } /** ONLY FOR DEBUGGING PURPOSES. For spec tests on error */ dumpCheckpointKeys(): string[] { return Array.from(this.cache.keys()); } /** * Prune or persist checkpoint states in an epoch * 1) If there is 1 checkpoint state with known root, persist it. This is when there is skipped slot at block 0 of epoch * slot: n * |-----------------------|-----------------------| * PRCS root | * * 2) If there are 2 checkpoint states, PRCS and CRCS and both roots are known to this state, persist CRCS. If the block is reorged, * PRCS is regen and populated to this cache again. * slot: n * |-----------------------|-----------------------| * PRCS root - prune | * CRCS root - persist | * * 3) If there are any roots that unknown to this state, persist their cp state. This is to handle the current block is reorged later * * 4) (derived from above) If there are 2 checkpoint states, PRCS and an unknown root, persist both. * - In the example below block slot (n + 1) reorged n * - If we process state n + 1, CRCS is unknown to it * - we need to also store CRCS to handle the case (n+2) switches to n again * * PRCS - persist * | processState() * | | * -------------n+1 * / | * n-1 ------n------------n+2 * | * CRCS - persist * * - PRCS is the checkpoint state that could be justified/finalized later based on the view of the state * - unknown root checkpoint state is persisted to handle the reorg back to that branch later * * Performance note: * - In normal condition, we persist 1 checkpoint state per epoch. * - In reorged condition, we may persist multiple (most likely 2) checkpoint states per epoch. */ private async processPastEpoch(blockRootHex: RootHex, state: IBeaconStateView, epoch: Epoch): Promise<number> { let persistCount = 0; const epochBoundarySlot = computeStartSlotAtEpoch(epoch); const epochBoundaryRoot = epochBoundarySlot === state.slot ? fromHex(blockRootHex) : state.getBlockRootAtSlot(epochBoundarySlot); const epochBoundaryHex = toRootHex(epochBoundaryRoot); const prevEpochRoot = toRootHex(state.getBlockRootAtSlot(epochBoundarySlot - 1)); // for each epoch, usually there are 2 rootHexes respective to the 2 checkpoint states: Previous Root Checkpoint State and Current Root Checkpoint State const cpRootHexes = this.epochIndex.get(epoch) ?? []; const persistedRootHexes = new Set<RootHex>(); // 1) if there is no CRCS, persist PRCS (block 0 of epoch is skipped). In this case prevEpochRoot === epochBoundaryHex // 2) if there are PRCS and CRCS, persist CRCS => persist CRCS // => this is simplified to always persist epochBoundaryHex persistedRootHexes.add(epochBoundaryHex); // 3) persist any states with unknown roots to this state for (const rootHex of cpRootHexes) { if (rootHex !== epochBoundaryHex && rootHex !== prevEpochRoot) { persistedRootHexes.add(rootHex); } } for (const rootHex of cpRootHexes) { const cpKey = toCacheKey({epoch: epoch, rootHex}); const cacheItem = this.cache.get(cpKey); if (cacheItem !== undefined && isInMemoryCacheItem(cacheItem)) { let {persistedKey} = cacheItem; const {state} = cacheItem; const logMeta = { stateSlot: state.slot, rootHex, epochBoundaryHex, persistedKey: persistedKey ? toHex(persistedKey) : "", }; if (persistedRootHexes.has(rootHex)) { if (persistedKey) { // we don't care if the checkpoint state is already persisted this.logger.verbose("Pruned checkpoint state from memory but no need to persist", logMeta); } else { // persist and do not update epochIndex this.metrics?.cpStateCache.statePersistSecFromSlot.observe( this.clock?.secFromSlot(this.clock?.currentSlot ?? 0) ?? 0 ); const cpPersist = {epoch: epoch, root: fromHex(rootHex)}; // It's not sustainable to allocate ~240MB for each state every epoch, so we use buffer pool to reuse the memory. // As monitored on holesky as of Jan 2024: // - This does not increase heap allocation while gc time is the same // - It helps stabilize persist time and save ~300ms in average (1.5s vs 1.2s) // - It also helps the state reload to save ~500ms in average (4.3s vs 3.8s) // - Also `serializeState.test.ts` perf test shows a lot of differences allocating ~240MB once vs per state serialization const timer = this.metrics?.stateSerializeDuration.startTimer({ source: AllocSource.PERSISTENT_CHECKPOINTS_CACHE_STATE, }); persistedKey = await serializeState( state, AllocSource.PERSISTENT_CHECKPOINTS_CACHE_STATE, (stateBytes) => { timer?.(); return this.datastore.write(cpPersist, stateBytes); }, this.bufferPool ); persistCount++; this.logger.verbose("Pruned checkpoint state from memory and persisted to disk", { ...logMeta, persistedKey: toHex(persistedKey), }); } // overwrite cpKey, this means the state is deleted from memory this.cache.set(cpKey, {type: CacheItemType.persisted, value: persistedKey}); } else { if (persistedKey) { // persisted file will be eventually deleted by the archive task // this also means the state is deleted from memory this.cache.set(cpKey, {type: CacheItemType.persisted, value: persistedKey}); // do not update epochIndex } else { // delete the state from memory this.cache.delete(cpKey); const rootSet = this.epochIndex.get(epoch); if (rootSet) { rootSet.delete(rootHex); if (rootSet.size === 0) { this.epochIndex.delete(epoch); } } } this.metrics?.cpStateCache.statePruneFromMemoryCount.inc(); this.logger.verbose("Pruned checkpoint state from memory", logMeta); } } } return persistCount; } /** * Delete all items of an epoch from disk and memory */ private async deleteAllEpochItems(epoch: Epoch): Promise<void> { let persistCount = 0; const rootHexes = this.epochIndex.get(epoch) || []; for (const rootHex of rootHexes) { const key = toCacheKey({rootHex, epoch}); const cacheItem = this.cache.get(key); if (cacheItem) { const persistedKey = isPersistedCacheItem(cacheItem) ? cacheItem.value : cacheItem.persistedKey; if (persistedKey) { await this.datastore.remove(persistedKey); persistCount++; this.metrics?.cpStateCache.persistedStateRemoveCount.inc(); } } this.cache.delete(key); } this.epochIndex.delete(epoch); this.logger.verbose("Pruned checkpoint states for epoch", { epoch, persistCount, rootHexes: Array.from(rootHexes).join(","), }); } /** * Prune persisted checkpoint states from disk. * Note that this should handle all possible errors and not throw. */ private prunePersistedStates(): void { // epochsOnDisk epochsInMemory // |----------------------------------------------------------|----------------------| const maxTrackedEpochs = this.maxEpochsOnDisk + this.maxEpochsInMemory; if (this.epochIndex.size <= maxTrackedEpochs) { return; } const sortedEpochs = Array.from(this.epochIndex.keys()).sort((a, b) => a - b); const pruneEpochs = sortedEpochs.slice(0, sortedEpochs.length - maxTrackedEpochs); for (const epoch of pruneEpochs) { this.deleteAllEpochItems(epoch).catch((e) => this.logger.debug( "Error delete all epoch items", {epoch, maxEpochsOnDisk: this.maxEpochsOnDisk, maxEpochsInMemory: this.maxEpochsInMemory}, e as Error ) ); } } /** * Serialize validators to bytes leveraging the buffer pool to save memory allocation. * - As monitored on holesky as of Jan 2024, it helps save ~500ms state reload time (4.3s vs 3.8s) * - Also `serializeState.test.ts` perf test shows a lot of differences allocating validators bytes once vs every time, * This is 2x - 3x faster than allocating memory every time. */ private serializeStateValidators(state: IBeaconStateView): BufferWithKey | null { const size = state.serializedValidatorsSize(); if (this.bufferPool) { const bufferWithKey = this.bufferPool.alloc(size, AllocSource.PERSISTENT_CHECKPOINTS_CACHE_VALIDATORS); if (bufferWithKey) { const validatorsBytes = bufferWithKey.buffer; const dataView = new DataView(validatorsBytes.buffer, validatorsBytes.byteOffset, validatorsBytes.byteLength); state.serializeValidatorsToBytes({uint8Array: validatorsBytes, dataView}, 0); return bufferWithKey; } } return null; } } export function toCheckpointHex(checkpoint: phase0.Checkpoint): CheckpointHex { return { epoch: checkpoint.epoch, rootHex: toRootHex(checkpoint.root), }; } export function toCheckpointKey(cp: CheckpointHex): string { return `${cp.rootHex}:${cp.epoch}`; } function toCacheKey(cp: CheckpointHex): CacheKey { return `${cp.rootHex}_${cp.epoch}`; } function fromCacheKey(key: CacheKey): CheckpointHex { const [rootHex, epoch] = key.split("_"); return { rootHex, epoch: Number(epoch), }; } function isBeaconStateView(stateOrBytes: IBeaconStateView | LoadedStateBytesData): stateOrBytes is IBeaconStateView { return (stateOrBytes as IBeaconStateView).slot !== undefined; } function isInMemoryCacheItem(cacheItem: CacheItem): cacheItem is InMemoryCacheItem { return cacheItem.type === CacheItemType.inMemory; } function isPersistedCacheItem(cacheItem: CacheItem): cacheItem is PersistedCacheItem { return cacheItem.type === CacheItemType.persisted; }