@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
337 lines • 16.1 kB
JavaScript
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