@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
428 lines • 22.5 kB
JavaScript
import path from "node:path";
import { PayloadStatus } from "@lodestar/fork-choice";
import { ForkSeq, SLOTS_PER_EPOCH } from "@lodestar/params";
import { computeEpochAtSlot, computeStartSlotAtEpoch } from "@lodestar/state-transition";
import { fromAsync, fromHex, prettyPrintIndices, toRootHex } from "@lodestar/utils";
import { ensureDir, writeIfNotExist } from "../../../util/file.js";
// Process in chunks to avoid OOM
// this number of blocks per chunk is tested in e2e test blockArchive.test.ts
// TODO: Review after merge since the size of blocks will increase significantly
const BLOCK_BATCH_SIZE = 256;
const BLOB_SIDECAR_BATCH_SIZE = 32;
/**
* Persist orphaned block to disk
*/
async function persistOrphanedBlock(slot, blockRoot, bytes, opts) {
const dirpath = path.join(opts.persistOrphanedBlocksDir ?? "orphaned_blocks");
const filepath = path.join(dirpath, `${slot}_${blockRoot}.ssz`);
await ensureDir(dirpath);
await writeIfNotExist(filepath, bytes);
}
/**
* Archives finalized blocks from active bucket to archive bucket.
*
* Only archive blocks on the same chain to the finalized checkpoint.
* Each run should move all finalized blocks to blockArhive db to make it consistent
* to stateArchive, so that the node always work well when we restart.
* Note that the finalized block still stay in forkchoice to check finalize checkpoint of next onBlock calls,
* the next run should not reprocess finalzied block of this run.
*/
export async function archiveBlocks(config, db, forkChoice, lightclientServer, logger, finalizedCheckpoint, currentEpoch, archiveDataEpochs, persistOrphanedBlocks, persistOrphanedBlocksDir) {
// Use fork choice to determine the blocks to archive and delete.
// `ancestors` is the canonical walk back from the finalized root, including the previous finalized
// block as its last element.
const { ancestors: finalizedCanonicalBlocks, nonAncestors: finalizedNonCanonicalBlocks } = forkChoice.getAllAncestorAndNonAncestorBlocksDefaultStatus(finalizedCheckpoint.rootHex);
// NOTE: The finalized block will be exactly the first block of `epoch` or previous
const finalizedPostDeneb = finalizedCheckpoint.epoch >= config.DENEB_FORK_EPOCH;
const finalizedPostFulu = finalizedCheckpoint.epoch >= config.FULU_FORK_EPOCH;
const finalizedPostGloas = finalizedCheckpoint.epoch >= config.GLOAS_FORK_EPOCH;
const finalizedCanonicalBlockRoots = finalizedCanonicalBlocks.map((block) => ({
slot: block.slot,
root: fromHex(block.blockRoot),
}));
const logCtx = { currentEpoch, finalizedEpoch: finalizedCheckpoint.epoch, finalizedRoot: finalizedCheckpoint.rootHex };
if (finalizedCanonicalBlockRoots.length > 0) {
const migratedSlots = await migrateBlocksFromHotToColdDb(db, logger, finalizedCanonicalBlockRoots);
logger.verbose("Migrated blocks from hot DB to cold DB", {
...logCtx,
fromSlot: finalizedCanonicalBlockRoots[0].slot,
toSlot: finalizedCanonicalBlockRoots.at(-1)?.slot,
size: finalizedCanonicalBlockRoots.length,
migratedEntries: migratedSlots.length,
slotRange: prettyPrintIndices(migratedSlots),
});
if (finalizedPostDeneb) {
const migratedEntries = await migrateBlobSidecarsFromHotToColdDb(config, db, logger, finalizedCanonicalBlockRoots, currentEpoch);
logger.verbose("Migrated blobSidecars from hot DB to cold DB", { ...logCtx, migratedEntries });
}
if (finalizedPostFulu) {
const migratedSlots = await migrateDataColumnSidecarsFromHotToColdDb(config, db, logger, finalizedCanonicalBlocks, currentEpoch);
logger.verbose("Migrated dataColumnSidecars from hot DB to cold DB", {
...logCtx,
migratedEntries: migratedSlots.length,
slotRange: prettyPrintIndices(migratedSlots),
});
}
if (finalizedPostGloas) {
const migratedSlots = await migrateExecutionPayloadEnvelopesFromHotToColdDb(config, db, logger, finalizedCanonicalBlocks);
logger.verbose("Migrated executionPayloadEnvelopes from hot DB to cold DB", {
...logCtx,
migratedEntries: migratedSlots.length,
slotRange: prettyPrintIndices(migratedSlots),
});
}
}
// deleteNonCanonicalBlocks
// loop through forkchoice single time
const nonCanonicalBlockRoots = finalizedNonCanonicalBlocks.map((summary) => fromHex(summary.blockRoot));
if (nonCanonicalBlockRoots.length > 0) {
if (persistOrphanedBlocks) {
// Persist orphaned blocks to disk before deleting them from hot db
await Promise.all(nonCanonicalBlockRoots.map(async (root, index) => {
const block = finalizedNonCanonicalBlocks[index];
const blockBytes = await db.block.getBinary(root);
const blockLogCtx = { slot: block.slot, root: block.blockRoot };
if (blockBytes) {
await persistOrphanedBlock(block.slot, block.blockRoot, blockBytes, {
persistOrphanedBlocksDir: persistOrphanedBlocksDir ?? "orphaned_blocks",
});
logger.verbose("Persisted orphaned block", { ...logCtx, ...blockLogCtx });
}
else {
logger.warn("Tried to persist orphaned block but no block found", { ...logCtx, ...blockLogCtx });
}
}));
}
const nonCanonicalSlots = finalizedNonCanonicalBlocks.map((summary) => summary.slot).sort((a, b) => a - b);
const nonCanonicalLogCtx = {
...logCtx,
count: nonCanonicalBlockRoots.length,
slotRange: prettyPrintIndices(nonCanonicalSlots),
};
await db.block.batchDelete(nonCanonicalBlockRoots);
logger.verbose("Deleted non canonical blocks from hot DB", nonCanonicalLogCtx);
if (finalizedPostDeneb) {
await db.blobSidecars.batchDelete(nonCanonicalBlockRoots);
logger.verbose("Deleted non canonical blobSidecars from hot DB", nonCanonicalLogCtx);
}
if (finalizedPostFulu) {
await db.dataColumnSidecar.deleteMany(nonCanonicalBlockRoots);
logger.verbose("Deleted non canonical dataColumnSidecars from hot DB", nonCanonicalLogCtx);
}
if (finalizedPostGloas) {
await db.executionPayloadEnvelope.batchDelete(nonCanonicalBlockRoots);
logger.verbose("Deleted non canonical executionPayloadEnvelopes from hot DB", nonCanonicalLogCtx);
}
}
// Delete expired blobs
// Keep only `[current_epoch - max(MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS, archiveDataEpochs)]`
// if archiveDataEpochs set to Infinity do not prune`
if (finalizedPostDeneb) {
if (archiveDataEpochs !== Infinity) {
const blobsArchiveWindow = Math.max(config.MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS, archiveDataEpochs ?? 0);
const blobSidecarsMinEpoch = currentEpoch - blobsArchiveWindow;
if (blobSidecarsMinEpoch >= config.DENEB_FORK_EPOCH) {
const slotsToDelete = await db.blobSidecarsArchive.keys({ lt: computeStartSlotAtEpoch(blobSidecarsMinEpoch) });
if (slotsToDelete.length > 0) {
await db.blobSidecarsArchive.batchDelete(slotsToDelete);
logger.verbose(`blobSidecars prune: batchDelete range ${slotsToDelete[0]}..${slotsToDelete.at(-1)}`, logCtx);
}
else {
logger.verbose(`blobSidecars prune: no entries before epoch ${blobSidecarsMinEpoch}`, logCtx);
}
}
}
else {
logger.verbose("blobSidecars pruning skipped: archiveDataEpochs set to Infinity", logCtx);
}
}
// Delete expired data column sidecars
// Keep only `[current_epoch - max(MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS, archiveDataEpochs)]`
if (finalizedPostFulu) {
if (archiveDataEpochs !== Infinity) {
const dataColumnSidecarsArchiveWindow = Math.max(config.MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS, archiveDataEpochs ?? 0);
const dataColumnSidecarsMinEpoch = currentEpoch - dataColumnSidecarsArchiveWindow;
if (dataColumnSidecarsMinEpoch >= config.FULU_FORK_EPOCH) {
const prefixedKeys = await db.dataColumnSidecarArchive.keys({
// The `id` value `0` refers to the column index. So we want to fetch all sidecars less than zero column of `dataColumnSidecarsMinEpoch`
lt: { prefix: computeStartSlotAtEpoch(dataColumnSidecarsMinEpoch), id: 0 },
});
// for each slot there could be multiple dataColumnSidecar, so we need to deduplicate it
const slotsToDelete = [...new Set(prefixedKeys.map(({ prefix }) => prefix))].sort((a, b) => a - b);
if (slotsToDelete.length > 0) {
await db.dataColumnSidecarArchive.deleteMany(slotsToDelete);
logger.verbose("dataColumnSidecars prune", {
...logCtx,
slotRange: prettyPrintIndices(slotsToDelete),
numOfSlots: slotsToDelete.length,
totalNumOfSidecars: prefixedKeys.length,
});
}
else {
logger.verbose(`dataColumnSidecars prune: no entries before epoch ${dataColumnSidecarsMinEpoch}`, logCtx);
}
}
else {
logger.verbose(`dataColumnSidecars pruning skipped: ${dataColumnSidecarsMinEpoch} is before fulu fork epoch ${config.FULU_FORK_EPOCH}`, logCtx);
}
}
else {
logger.verbose("dataColumnSidecars pruning skipped: archiveDataEpochs set to Infinity", logCtx);
}
}
// Prunning potential checkpoint data
const finalizedCanonicalNonCheckpointBlocks = getNonCheckpointBlocks(finalizedCanonicalBlockRoots);
const nonCheckpointBlockRoots = [...nonCanonicalBlockRoots];
for (const block of finalizedCanonicalNonCheckpointBlocks) {
nonCheckpointBlockRoots.push(block.root);
}
if (lightclientServer) {
await lightclientServer.pruneNonCheckpointData(nonCheckpointBlockRoots);
}
logger.verbose("Archiving of finalized blocks complete", {
...logCtx,
totalArchived: finalizedCanonicalBlocks.length,
});
}
async function migrateBlocksFromHotToColdDb(db, logger, blocks) {
// The input includes the previous finalized block as the last ancestor; its SignedBeaconBlock
// was archived on a previous run and is no longer in hot db. `getBinary` returning null for any
// block in the batch is therefore treated as "already migrated, skip" rather than an error.
const migratedSlots = [];
for (let i = 0; i < blocks.length; i += BLOCK_BATCH_SIZE) {
const toIdx = Math.min(i + BLOCK_BATCH_SIZE, blocks.length);
const canonicalBlocks = blocks.slice(i, toIdx);
if (canonicalBlocks.length === 0)
break;
// load Buffer instead of SignedBeaconBlock to improve performance
const canonicalBlockEntries = (await Promise.all(canonicalBlocks.map(async (block) => {
const blockBuffer = await db.block.getBinary(block.root);
if (!blockBuffer) {
logger.debug("Block in forkchoice but missing in hot db, could be already archived", {
slot: block.slot,
root: toRootHex(block.root),
});
return null;
}
return {
key: block.slot,
value: blockBuffer,
slot: block.slot,
blockRoot: block.root,
// TODO: Benchmark if faster to slice Buffer or fromHex()
parentRoot: getParentRootFromSignedBlock(blockBuffer),
};
}))).filter((entry) => entry !== null);
if (canonicalBlockEntries.length === 0)
continue;
await Promise.all([
db.blockArchive.batchPutBinary(canonicalBlockEntries),
db.block.batchDelete(canonicalBlockEntries.map((entry) => entry.blockRoot)),
]);
for (const entry of canonicalBlockEntries)
migratedSlots.push(entry.slot);
}
// Ancestor walk is newest → oldest; sort ascending so `prettyPrintIndices` renders cleanly.
return migratedSlots.sort((a, b) => a - b);
}
/**
* Migrate blobSidecars from hot db to cold db.
* @returns true if we do that, false if block is out of range data.
*/
async function migrateBlobSidecarsFromHotToColdDb(config, db, logger, blocks, currentEpoch) {
let migratedWrappedBlobSidecars = 0;
for (let i = 0; i < blocks.length; i += BLOB_SIDECAR_BATCH_SIZE) {
const toIdx = Math.min(i + BLOB_SIDECAR_BATCH_SIZE, blocks.length);
const canonicalBlocks = blocks.slice(i, toIdx);
// processCanonicalBlocks
if (canonicalBlocks.length === 0)
break;
// load Buffer instead of ssz deserialized to improve performance
const canonicalBlobSidecarsEntries = (await Promise.all(canonicalBlocks
.filter((block) => {
const blockSlot = block.slot;
const blockEpoch = computeEpochAtSlot(blockSlot);
const forkSeq = config.getForkSeq(blockSlot);
return (forkSeq >= ForkSeq.deneb &&
forkSeq < ForkSeq.fulu &&
// if block is out of ${config.MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS}, skip this step
blockEpoch >= currentEpoch - config.MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS);
})
.map(async (block) => {
// The ancestor walk includes the boundary (previous finalized) block; on first
// finalization that boundary is the anchor which has no blob sidecars in hot db.
// Treat a null hot-db entry as "nothing to migrate" rather than an error.
const bytes = await db.blobSidecars.getBinary(block.root);
if (!bytes) {
logger.debug("BlobSidecars in forkchoice but missing in hot db, could be already archived", {
slot: block.slot,
root: toRootHex(block.root),
});
return null;
}
return { key: block.slot, value: bytes };
}))).filter((e) => e !== null);
// put to blockArchive db and delete block db
await Promise.all([
db.blobSidecarsArchive.batchPutBinary(canonicalBlobSidecarsEntries),
db.blobSidecars.batchDelete(canonicalBlocks.map((block) => block.root)),
]);
migratedWrappedBlobSidecars += canonicalBlobSidecarsEntries.length;
}
return migratedWrappedBlobSidecars;
}
// TODO: This function can be simplified further by reducing layers of promises in a loop
/**
* Post-gloas the data columns of a Gloas block are tied to its execution payload envelope —
* columns only exist once the FULL variant of the block is in the proto-array. Pre-Gloas (Fulu)
* blocks only have a FULL variant, so the `payloadStatus === FULL` filter passes them all.
* Blocks whose canonical variant is PENDING/EMPTY are skipped here — their columns will be picked
* up on a later run once the FULL variant appears in the ancestor walk.
*/
async function migrateDataColumnSidecarsFromHotToColdDb(config, db, logger, canonicalBlocks, currentEpoch) {
const columnBlocks = canonicalBlocks.filter((block) => config.getForkSeq(block.slot) < ForkSeq.gloas || block.payloadStatus === PayloadStatus.FULL);
if (columnBlocks.length === 0)
return [];
const blocks = columnBlocks.map((block) => ({ slot: block.slot, root: fromHex(block.blockRoot) }));
const migratedSlots = [];
for (let i = 0; i < blocks.length; i += BLOB_SIDECAR_BATCH_SIZE) {
const toIdx = Math.min(i + BLOB_SIDECAR_BATCH_SIZE, blocks.length);
const batch = blocks.slice(i, toIdx);
if (batch.length === 0)
break;
const promises = [];
// load Buffer instead of ssz deserialized to improve performance
for (const block of batch) {
const blockSlot = block.slot;
const blockEpoch = computeEpochAtSlot(blockSlot);
if (config.getForkSeq(blockSlot) < ForkSeq.fulu ||
// if block is out of ${config.MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS}, skip this step
blockEpoch < currentEpoch - config.MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS) {
continue;
}
// Here we assume the data column sidecars are already in the hot db
const dataColumnSidecarBytes = await fromAsync(db.dataColumnSidecar.valuesStreamBinary(block.root));
if (dataColumnSidecarBytes.length === 0) {
// Empty stream: either the block has no blobs, or columns were already archived on a
// previous run (boundary block). Nothing to migrate.
logger.debug("DataColumnSidecars in forkchoice but missing in hot db, could be already archived", {
slot: block.slot,
root: toRootHex(block.root),
});
continue;
}
logger.verbose("Migrated dataColumnSidecars for block", {
currentEpoch,
slot: block.slot,
root: toRootHex(block.root),
numSidecars: dataColumnSidecarBytes.length,
});
promises.push(db.dataColumnSidecarArchive.putManyBinary(block.slot, dataColumnSidecarBytes.map((p) => ({ key: p.id, value: p.value }))));
migratedSlots.push(block.slot);
}
promises.push(db.dataColumnSidecar.deleteMany(batch.map((block) => block.root)));
await Promise.all(promises);
}
// Ancestor walk is newest → oldest; sort ascending so `prettyPrintIndices` renders cleanly.
return migratedSlots.sort((a, b) => a - b);
}
/**
* Post-gloas given a finalized checkpoint at a block root, payload of that block root
* is not considered finalized, hence they are archived in the next run.
*/
async function migrateExecutionPayloadEnvelopesFromHotToColdDb(config, db, logger, canonicalBlocks) {
const payloadBlocks = canonicalBlocks.filter((block) => config.getForkSeq(block.slot) < ForkSeq.gloas || block.payloadStatus === PayloadStatus.FULL);
if (payloadBlocks.length === 0)
return [];
const blocks = payloadBlocks.map((block) => ({ slot: block.slot, root: fromHex(block.blockRoot) }));
const envelopeEntries = [];
const migratedRoots = [];
const envelopeBytesArray = await Promise.all(blocks.map((block) => db.executionPayloadEnvelope.getBinary(block.root)));
for (let i = 0; i < blocks.length; i++) {
const bytes = envelopeBytesArray[i];
if (bytes !== null) {
envelopeEntries.push({ key: blocks[i].slot, value: bytes });
migratedRoots.push(blocks[i].root);
}
else {
logger.debug("ExecutionPayloadEnvelope in forkchoice but missing in hot db, could be already archived", {
slot: blocks[i].slot,
root: toRootHex(blocks[i].root),
});
}
}
if (envelopeEntries.length === 0)
return [];
await Promise.all([
db.executionPayloadEnvelopeArchive.batchPutBinary(envelopeEntries),
db.executionPayloadEnvelope.batchDelete(migratedRoots),
]);
// Slots are ascending in hot-db key order — sort to guarantee `prettyPrintIndices` output is clean
// regardless of ancestor-walk order (newest → oldest).
return envelopeEntries.map((entry) => entry.key).sort((a, b) => a - b);
}
/**
* ```
* class SignedBeaconBlock(Container):
* message: BeaconBlock [offset - 4 bytes]
* signature: BLSSignature [fixed - 96 bytes]
*
* class BeaconBlock(Container):
* slot: Slot [fixed - 8 bytes]
* proposer_index: ValidatorIndex [fixed - 8 bytes]
* parent_root: Root [fixed - 32 bytes]
* state_root: Root
* body: BeaconBlockBody
* ```
* From byte: `4 + 96 + 8 + 8 = 116`
* To byte: `116 + 32 = 148`
*/
export function getParentRootFromSignedBlock(bytes) {
return bytes.slice(116, 148);
}
/**
*
* @param blocks sequence of linear blocks, from child to ancestor.
* In ProtoArray.getAllAncestorNodes child nodes are pushed first to the returned array.
*/
export function getNonCheckpointBlocks(blocks) {
// Iterate from lowest child to highest ancestor
// Look for the checkpoint of the lowest epoch
// If block at `epoch * SLOTS_PER_EPOCH`, it's a checkpoint.
// - Then for the previous epoch all blocks but the 0 are NOT checkpoints
// - Otherwise for the previous epoch the last block is a checkpoint
if (blocks.length < 1) {
return [];
}
const nonCheckpointBlocks = [];
// Start with Infinity to always trigger `blockEpoch < epochPtr` in the first loop
let epochPtr = Infinity;
// Assume worst case, since it's unknown if a future epoch will skip the first slot or not.
// This function must return only blocks that are guaranteed to never become checkpoints.
let epochPtrHasFirstSlot = false;
// blocks order: from child to ancestor, decreasing slot
for (let i = 0; i < blocks.length; i++) {
let isCheckpoint = false;
const block = blocks[i];
const blockEpoch = computeEpochAtSlot(block.slot);
if (blockEpoch < epochPtr) {
// If future epoch has skipped the first slot, the last block in the previous epoch is a checkpoint
if (!epochPtrHasFirstSlot) {
isCheckpoint = true;
}
// Reset epoch pointer
epochPtr = blockEpoch;
epochPtrHasFirstSlot = false;
}
// The block in the first slot of an epoch is always a checkpoint slot
if (block.slot % SLOTS_PER_EPOCH === 0) {
epochPtrHasFirstSlot = true;
isCheckpoint = true;
}
if (!isCheckpoint) {
nonCheckpointBlocks.push(block);
}
}
return nonCheckpointBlocks;
}
//# sourceMappingURL=archiveBlocks.js.map