UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

337 lines • 16.1 kB
import { ATTESTATION_SUBNET_COUNT, EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION, SLOTS_PER_EPOCH } from "@lodestar/params"; import { computeEpochAtSlot } from "@lodestar/state-transition"; import { ssz } from "@lodestar/types"; import { MapDef } from "@lodestar/utils"; import { ClockEvent } from "../../util/clock.js"; import { getActiveForkBoundaries } from "../forks.js"; import { GossipType } from "../gossip/index.js"; import { GOSSIP_D_LOW } from "../gossip/scoringParameters.js"; import { stringifyGossipTopic } from "../gossip/topic.js"; import { SubnetMap } from "../peers/utils/index.js"; import { computeSubscribedSubnet } from "./util.js"; const gossipType = GossipType.beacon_attestation; export var SubnetSource; (function (SubnetSource) { SubnetSource["committee"] = "committee"; SubnetSource["longLived"] = "long_lived"; })(SubnetSource || (SubnetSource = {})); /** * This value means node is not able to form stable mesh. */ const NOT_ABLE_TO_FORM_STABLE_MESH_SEC = -1; /** * Manage deleterministic long lived (DLL) subnets and short lived subnets. * - PeerManager uses attnetsService to know which peers are required for duties and long lived subscriptions * - Network call addCommitteeSubscriptions() from API calls * - Gossip handler checks shouldProcess to know if validator is aggregator */ export class AttnetsService { constructor(config, clock, gossip, metadata, logger, metrics, nodeId, opts) { this.config = config; this.clock = clock; this.gossip = gossip; this.metadata = metadata; this.logger = logger; this.metrics = metrics; this.nodeId = nodeId; this.opts = opts; /** Committee subnets - PeerManager must find peers for those */ this.committeeSubnets = new SubnetMap(); /** * All currently subscribed short-lived subnets, for attestation aggregation * This class will tell gossip to subscribe and un-subscribe * If a value exists for `SubscriptionId` it means that gossip subscription is active in network.gossip */ this.shortLivedSubscriptions = new SubnetMap(); /** ${SUBNETS_PER_NODE} long lived subscriptions, may overlap with `shortLivedSubscriptions` */ this.longLivedSubscriptions = new Set(); /** * Map of an aggregator at a slot and AggregatorDutyInfo * Used to determine if we should process an attestation. */ this.aggregatorSlotSubnet = new MapDef(() => new Map()); /** * Run per slot. * - Subscribe to gossip subnets 2 slots in advance * - Unsubscribe from expired subnets * - Track time to stable mesh if not yet formed */ this.onSlot = (clockSlot) => { try { setTimeout(() => { this.onHalfSlot(clockSlot); }, this.config.SECONDS_PER_SLOT * 0.5 * 1000); for (const [dutiedSlot, dutiedInfo] of this.aggregatorSlotSubnet.entries()) { if (dutiedSlot === clockSlot + this.opts.slotsToSubscribeBeforeAggregatorDuty) { // Trigger gossip subscription first, in batch if (dutiedInfo.size > 0) { this.subscribeToSubnets(Array.from(dutiedInfo.keys()), SubnetSource.committee); } // Then, register the subscriptions for (const subnet of dutiedInfo.keys()) { this.shortLivedSubscriptions.request({ subnet, toSlot: dutiedSlot }); } } this.trackTimeToStableMesh(clockSlot, dutiedSlot, dutiedInfo); } this.unsubscribeExpiredCommitteeSubnets(clockSlot); this.pruneExpiredAggregator(clockSlot); } catch (e) { this.logger.error("Error on AttnetsService.onSlot", { slot: clockSlot }, e); } }; this.onHalfSlot = (clockSlot) => { for (const [dutiedSlot, dutiedInfo] of this.aggregatorSlotSubnet.entries()) { this.trackTimeToStableMesh(clockSlot, dutiedSlot, dutiedInfo); } }; /** * Run per epoch, clean-up operations that are not urgent * Subscribe to new random subnets every EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION epochs */ this.onEpoch = (epoch) => { try { if (epoch % EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION === 0) { this.recomputeLongLivedSubnets(); } } catch (e) { this.logger.error("Error on AttnetsService.onEpoch", { epoch }, e); } }; // if subscribeAllSubnets, we act like we have >= ATTESTATION_SUBNET_COUNT validators connecting to this node // so that we have enough subnet topic peers, see https://github.com/ChainSafe/lodestar/issues/4921 if (this.opts.subscribeAllSubnets) { for (let subnet = 0; subnet < ATTESTATION_SUBNET_COUNT; subnet++) { this.committeeSubnets.request({ subnet, toSlot: Infinity }); } } if (metrics) { metrics.attnetsService.longLivedSubscriptions.addCollect(() => this.onScrapeLodestarMetrics(metrics)); } this.recomputeLongLivedSubnets(); this.clock.on(ClockEvent.slot, this.onSlot); this.clock.on(ClockEvent.epoch, this.onEpoch); } close() { this.clock.off(ClockEvent.slot, this.onSlot); this.clock.off(ClockEvent.epoch, this.onEpoch); } /** * Get all active subnets for the hearbeat: * - committeeSubnets so that submitted attestations can be spread to the network * - longLivedSubscriptions because other peers based on this node's ENR for their submitted attestations */ getActiveSubnets() { const shortLivedSubnets = this.committeeSubnets.getActiveTtl(this.clock.currentSlot); const longLivedSubscriptionsToSlot = (Math.floor(this.clock.currentEpoch / EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION) + 1) * EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION * SLOTS_PER_EPOCH; const longLivedSubnets = Array.from(this.longLivedSubscriptions).map((subnet) => ({ subnet, toSlot: longLivedSubscriptionsToSlot, })); // could be overlap, PeerDiscovery will handle it return [...shortLivedSubnets, ...longLivedSubnets]; } /** * Called from the API when validator is a part of a committee. */ addCommitteeSubscriptions(subscriptions) { for (const { subnet, slot, isAggregator } of subscriptions) { // the peer-manager heartbeat will help find the subnet this.committeeSubnets.request({ subnet, toSlot: slot + 1 }); if (isAggregator) { // need exact slot here this.aggregatorSlotSubnet.getOrDefault(slot).set(subnet, null); } } } /** * Check if a subscription is still active before handling a gossip object */ shouldProcess(subnet, slot) { if (!this.aggregatorSlotSubnet.has(slot)) { return false; } return this.aggregatorSlotSubnet.getOrDefault(slot).has(subnet); } /** * TODO-dll: clarify how many epochs before the fork we should subscribe to the new fork * Call ONLY ONCE: Two epoch before the fork, re-subscribe all existing random subscriptions to the new fork **/ subscribeSubnetsNextBoundary(boundary) { this.logger.info("Subscribing to long lived attnets for next fork boundary", { ...boundary, subnets: Array.from(this.longLivedSubscriptions).join(","), }); for (const subnet of this.longLivedSubscriptions) { this.gossip.subscribeTopic({ type: gossipType, subnet, boundary }); } } /** * TODO-dll: clarify how many epochs after the fork we should unsubscribe to the new fork * Call ONLY ONCE: Two epochs after the fork, un-subscribe all subnets from the old fork **/ unsubscribeSubnetsPrevBoundary(boundary) { this.logger.info("Unsubscribing from long lived attnets of previous fork boundary", boundary); for (let subnet = 0; subnet < ATTESTATION_SUBNET_COUNT; subnet++) { if (!this.opts.subscribeAllSubnets) { this.gossip.unsubscribeTopic({ type: gossipType, subnet, boundary }); } } } /** * Track time to form stable mesh if not yet formed */ trackTimeToStableMesh(clockSlot, dutiedSlot, dutiedInfo) { if (dutiedSlot < clockSlot) { // aggregator duty is expired, set timeToStableMesh to some big value so we know this value is not good for (const [subnet, timeToFormMesh] of dutiedInfo.entries()) { if (timeToFormMesh === null) { dutiedInfo.set(subnet, NOT_ABLE_TO_FORM_STABLE_MESH_SEC); this.metrics?.attnetsService.subscriptionsCommitteeTimeToStableMesh.observe({ subnet }, NOT_ABLE_TO_FORM_STABLE_MESH_SEC); } } } else if (dutiedSlot <= clockSlot + this.opts.slotsToSubscribeBeforeAggregatorDuty) { // aggregator duty is not expired, track time to stable mesh if this is the 1st time we see mesh peers>=Dlo (6) for (const [subnet, timeToFormMesh] of dutiedInfo.entries()) { if (timeToFormMesh === null) { const topicStr = stringifyGossipTopic(this.config, { type: gossipType, boundary: this.config.getForkBoundaryAtEpoch(computeEpochAtSlot(dutiedSlot)), subnet, }); const numMeshPeers = this.gossip.mesh.get(topicStr)?.size ?? 0; if (numMeshPeers >= GOSSIP_D_LOW) { const timeToStableMeshSec = this.clock.secFromSlot(dutiedSlot - this.opts.slotsToSubscribeBeforeAggregatorDuty); // set to dutiedInfo so we'll not set to metrics again dutiedInfo.set(subnet, timeToStableMeshSec); this.metrics?.attnetsService.subscriptionsCommitteeTimeToStableMesh.observe({ subnet }, timeToStableMeshSec); } } } } } recomputeLongLivedSubnets() { if (this.nodeId === null) { this.logger.verbose("Cannot recompute long-lived subscriptions, no nodeId"); return; } const oldSubnets = this.longLivedSubscriptions; const newSubnets = computeSubscribedSubnet(this.nodeId, this.clock.currentEpoch); this.logger.verbose("Recomputing long-lived subscriptions", { epoch: this.clock.currentEpoch, oldSubnets: Array.from(oldSubnets).join(","), newSubnets: newSubnets.join(","), }); const toRemoveSubnets = []; for (const subnet of oldSubnets) { if (!newSubnets.includes(subnet)) { toRemoveSubnets.push(subnet); } } // First, tell gossip to subscribe to the subnets if not connected already this.subscribeToSubnets(newSubnets, SubnetSource.longLived); // then update longLivedSubscriptions for (const subnet of toRemoveSubnets) { this.longLivedSubscriptions.delete(subnet); } for (const subnet of newSubnets) { // this.longLivedSubscriptions is a set so it'll handle duplicates this.longLivedSubscriptions.add(subnet); } // Only tell gossip to unsubsribe last, longLivedSubscriptions has the latest state this.unsubscribeSubnets(toRemoveSubnets, this.clock.currentSlot, SubnetSource.longLived); this.updateMetadata(); } /** * Unsubscribe to a committee subnet from subscribedCommitteeSubnets. * If a random subnet is present, we do not unsubscribe from it. */ unsubscribeExpiredCommitteeSubnets(slot) { const expired = this.shortLivedSubscriptions.getExpired(slot); if (expired.length > 0) { this.unsubscribeSubnets(expired, slot, SubnetSource.committee); } } /** * No need to track aggregator for past slots. * @param currentSlot */ pruneExpiredAggregator(currentSlot) { for (const dutiedSlot of this.aggregatorSlotSubnet.keys()) { if (currentSlot > dutiedSlot) { this.aggregatorSlotSubnet.delete(dutiedSlot); } } } /** Update ENR */ updateMetadata() { const subnets = ssz.phase0.AttestationSubnets.defaultValue(); for (const subnet of this.longLivedSubscriptions) { subnets.set(subnet, true); } // Only update metadata if necessary, setting `metadata.[key]` triggers a write to disk if (!ssz.phase0.AttestationSubnets.equals(subnets, this.metadata.attnets)) { this.metadata.attnets = subnets; } } /** * Trigger a gossip subcription only if not already subscribed * shortLivedSubscriptions or longLivedSubscriptions should be updated right AFTER this called **/ subscribeToSubnets(subnets, src) { const boundaries = getActiveForkBoundaries(this.config, this.clock.currentEpoch); for (const subnet of subnets) { if (!this.shortLivedSubscriptions.has(subnet) && !this.longLivedSubscriptions.has(subnet)) { for (const boundary of boundaries) { this.gossip.subscribeTopic({ type: gossipType, subnet, boundary }); } this.metrics?.attnetsService.subscribeSubnets.inc({ subnet, src }); } } } /** * Trigger a gossip un-subscription only if no-one is still subscribed * If unsubscribe long lived subnets, longLivedSubscriptions should be updated right BEFORE this called **/ unsubscribeSubnets(subnets, slot, src) { // No need to unsubscribeTopic(). Return early to prevent repetitive extra work if (this.opts.subscribeAllSubnets) return; const boundaries = getActiveForkBoundaries(this.config, this.clock.currentEpoch); for (const subnet of subnets) { if (!this.shortLivedSubscriptions.isActiveAtSlot(subnet, slot) && !this.longLivedSubscriptions.has(subnet)) { for (const boundary of boundaries) { this.gossip.unsubscribeTopic({ type: gossipType, subnet, boundary }); } this.metrics?.attnetsService.unsubscribeSubnets.inc({ subnet, src }); } } } onScrapeLodestarMetrics(metrics) { metrics.attnetsService.committeeSubnets.set(this.committeeSubnets.size); metrics.attnetsService.subscriptionsCommittee.set(this.shortLivedSubscriptions.size); // track short lived subnet status, >= 6 (Dlo) means healthy, otherwise unhealthy const currentSlot = this.clock.currentSlot; for (const { subnet } of this.shortLivedSubscriptions.getActiveTtl(currentSlot)) { const topicStr = stringifyGossipTopic(this.config, { type: gossipType, boundary: this.config.getForkBoundaryAtEpoch(this.clock.currentEpoch), subnet, }); const numMeshPeers = this.gossip.mesh.get(topicStr)?.size ?? 0; metrics.attnetsService.subscriptionsCommitteeMeshPeers.observe({ subnet }, numMeshPeers); } metrics.attnetsService.longLivedSubscriptions.set(this.longLivedSubscriptions.size); let aggregatorCount = 0; for (const subnets of this.aggregatorSlotSubnet.values()) { aggregatorCount += subnets.size; } metrics.attnetsService.aggregatorSlotSubnetCount.set(aggregatorCount); } } //# sourceMappingURL=attnetsService.js.map