-
-
Notifications
You must be signed in to change notification settings - Fork 464
feat: implement head_v2 event
#9486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Changes from all commits
7898bad
2ad8a52
eecf451
484d8ce
9e76ba8
471f5ef
78fbdd8
142d9c5
445da6b
9c42ce9
8a35439
6ae1fee
fd0b255
bb28875
656b16f
ea0a5c8
314f04a
e03ab7f
49b6780
94890ac
e9dae84
30cfaf3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import {routes} from "@lodestar/api"; | ||
| import {EpochDifference, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; | ||
| import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; | ||
| import {isOptimisticBlock} from "../../util/forkChoice.js"; | ||
| import {BeaconChain} from "../chain.js"; | ||
|
|
||
| export function emitHeadV2(this: BeaconChain, head: ProtoBlock, headChanged: boolean) { | ||
| if ( | ||
| headChanged || | ||
| !this.headV2PayloadStatusCache.has(head.blockRoot) || | ||
| (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && | ||
| head.payloadStatus === PayloadStatus.FULL) | ||
| ) { | ||
| this.emitter.emit(routes.events.EventType.headV2, { | ||
| version: this.config.getForkName(head.slot), | ||
| data: { | ||
| slot: head.slot, | ||
| block: head.blockRoot, | ||
| state: head.stateRoot, | ||
| payloadStatus: toApiPayloadStatus(head.payloadStatus), | ||
| epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot, | ||
| currentEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.previous), | ||
| nextEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.current), | ||
| executionOptimistic: isOptimisticBlock(head), | ||
| }, | ||
| }); | ||
| this.headV2PayloadStatusCache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); | ||
| this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); | ||
| } | ||
| } | ||
|
|
||
| function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { | ||
| switch (status) { | ||
| case PayloadStatus.FULL: | ||
| return "full"; | ||
| default: | ||
| return "empty"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,14 @@ import path from "node:path"; | |
| import {PrivateKey} from "@libp2p/interface"; | ||
| import {Type} from "@chainsafe/ssz"; | ||
| import {BeaconConfig} from "@lodestar/config"; | ||
| import {CheckpointWithHex, ForkChoiceStateGetter, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; | ||
| import { | ||
| CheckpointWithHex, | ||
| ForkChoiceStateGetter, | ||
| IForkChoice, | ||
| PayloadStatus, | ||
| ProtoBlock, | ||
| UpdateHeadOpt, | ||
| } from "@lodestar/fork-choice"; | ||
| import {LoggerNode} from "@lodestar/logger/node"; | ||
| import { | ||
| EFFECTIVE_BALANCE_INCREMENT, | ||
|
|
@@ -222,6 +229,12 @@ export class BeaconChain implements IBeaconChain { | |
|
|
||
| readonly opts: IChainOptions; | ||
|
|
||
| /** | ||
| * Cache of head block payload status at time of head_v2 emission, used to detect | ||
| * empty -> full transitions and emit a second head_v2 event accordingly. | ||
| */ | ||
| readonly headV2PayloadStatusCache = new Map<RootHex, {status: PayloadStatus; slot: Slot}>(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not entirely clear to me why we need a cache for this, the second head event can be emitted if your head in fork choice moves from empty to full
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well, I added the cache to prevent duplicate emissions between
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I raised something similar in the PR itself, to me emitting head twice per slot still feels strange but that's what we are supposed to do, I would need to re-read what the decision was on the PR, if it's up to me I would probably get rid of |
||
|
|
||
| protected readonly blockProcessor: BlockProcessor; | ||
| protected readonly payloadEnvelopeProcessor: PayloadEnvelopeProcessor; | ||
| protected readonly db: IBeaconDb; | ||
|
|
@@ -1482,6 +1495,14 @@ export class BeaconChain implements IBeaconChain { | |
| pruneSetToMax(this.blockProductionCache, this.opts.maxCachedProducedRoots ?? DEFAULT_MAX_CACHED_PRODUCED_RESULTS); | ||
| this.metrics?.blockProductionCacheSize.set(this.blockProductionCache.size); | ||
|
|
||
| // Prune outdated head v2 payload statuses | ||
| for (const [blockRoot, value] of this.headV2PayloadStatusCache.entries()) { | ||
| if (value.slot + SLOTS_PER_EPOCH < slot) { | ||
| this.headV2PayloadStatusCache.delete(blockRoot); | ||
| } | ||
| } | ||
| this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); | ||
|
|
||
| const metrics = this.metrics; | ||
| if (metrics && (slot + 1) % SLOTS_PER_EPOCH === 0) { | ||
| // On the last slot of the epoch | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import {beforeEach, describe, expect, it} from "vitest"; | ||
| import {routes} from "@lodestar/api"; | ||
| import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; | ||
| import {DataAvailabilityStatus} from "@lodestar/state-transition"; | ||
| import {emitHeadV2} from "../../../../src/chain/blocks/headV2Event.js"; | ||
| import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; | ||
|
|
||
| const {EventType} = routes.events; | ||
|
|
||
| const blockRoot1 = "0xaabb"; | ||
| const blockRoot2 = "0xbbcc"; | ||
|
|
||
| function protoBlock(blockRoot: string, slot: number, payloadStatus: PayloadStatus): ProtoBlock { | ||
| return { | ||
| slot, | ||
| blockRoot, | ||
| parentRoot: blockRoot, | ||
| stateRoot: blockRoot, | ||
| targetRoot: blockRoot, | ||
| justifiedEpoch: 0, | ||
| justifiedRoot: blockRoot, | ||
| finalizedEpoch: 0, | ||
| finalizedRoot: blockRoot, | ||
| unrealizedJustifiedEpoch: 0, | ||
| unrealizedJustifiedRoot: blockRoot, | ||
| unrealizedFinalizedEpoch: 0, | ||
| unrealizedFinalizedRoot: blockRoot, | ||
| timeliness: false, | ||
| executionPayloadBlockHash: null, | ||
| executionStatus: ExecutionStatus.PreMerge, | ||
| dataAvailabilityStatus: DataAvailabilityStatus.PreData, | ||
| payloadStatus, | ||
| parentBlockHash: null, | ||
| }; | ||
| } | ||
|
|
||
| describe("head_v2 event emission", () => { | ||
| let chain: MockedBeaconChain; | ||
| const slot = 10; | ||
|
|
||
| beforeEach(() => { | ||
| chain = getMockedBeaconChain(); | ||
| chain.forkChoice.getDependentRoot.mockReturnValue("0x1234"); | ||
| }); | ||
|
|
||
| it("emits head_v2 with empty on first import", () => { | ||
| const events: routes.events.EventData[typeof EventType.headV2][] = []; | ||
| chain.emitter.on(EventType.headV2, (data) => events.push(data)); | ||
|
|
||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); | ||
|
|
||
| expect(events).toHaveLength(1); | ||
| expect(events[0].data.payloadStatus).toBe("empty"); | ||
| }); | ||
|
|
||
| it("emits head_v2 with full on empty→full transition", () => { | ||
| const events: routes.events.EventData[typeof EventType.headV2][] = []; | ||
| chain.emitter.on(EventType.headV2, (data) => events.push(data)); | ||
|
|
||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); | ||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), false); | ||
|
|
||
| expect(events).toHaveLength(2); | ||
| expect(events[0].data.payloadStatus).toBe("empty"); | ||
| expect(events[1].data.payloadStatus).toBe("full"); | ||
| }); | ||
|
|
||
| it("does not re-emit on empty→empty", () => { | ||
| const events: routes.events.EventData[typeof EventType.headV2][] = []; | ||
| chain.emitter.on(EventType.headV2, (data) => events.push(data)); | ||
|
|
||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); | ||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), false); | ||
|
|
||
| expect(events).toHaveLength(1); | ||
| expect(events[0].data.payloadStatus).toBe("empty"); | ||
| }); | ||
|
|
||
| it("does not re-emit on full→full", () => { | ||
| const events: routes.events.EventData[typeof EventType.headV2][] = []; | ||
| chain.emitter.on(EventType.headV2, (data) => events.push(data)); | ||
|
|
||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); | ||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), false); | ||
|
|
||
| expect(events).toHaveLength(1); | ||
| expect(events[0].data.payloadStatus).toBe("full"); | ||
| }); | ||
|
|
||
| it("re-emits on head change to already-cached root (reorg)", () => { | ||
| const events: routes.events.EventData[typeof EventType.headV2][] = []; | ||
| chain.emitter.on(EventType.headV2, (data) => events.push(data)); | ||
|
|
||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); | ||
| emitHeadV2.call(chain, protoBlock(blockRoot2, slot, PayloadStatus.FULL), true); | ||
| emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); | ||
|
|
||
| expect(events).toHaveLength(3); | ||
| expect(events[0].data.payloadStatus).toBe("full"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,12 +39,14 @@ export class ChainHeaderTracker { | |
| start(signal: AbortSignal): void { | ||
| this.logger.verbose("Subscribing to validator events"); | ||
|
|
||
| const topics = [EventType.head]; | ||
| // We wait until the gloas fork is configured to avoid breaking | ||
| // connections with pre-gloas beacon nodes | ||
| if (this.config.GLOAS_FORK_EPOCH !== Infinity) { | ||
| topics.push(EventType.executionPayloadAvailable); | ||
| } | ||
| // Post-gloas BNs are required to support head_v2 per beacon-APIs#590, | ||
| // so we assume any gloas-configured BN will accept this topic. | ||
| const topics = | ||
| this.config.GLOAS_FORK_EPOCH === Infinity | ||
| ? [EventType.head] | ||
| : [EventType.headV2, EventType.executionPayloadAvailable]; | ||
|
|
||
| this.api.events | ||
| .eventstream({ | ||
|
|
@@ -74,6 +76,7 @@ export class ChainHeaderTracker { | |
| } | ||
|
|
||
| private onEvent = (event: routes.events.BeaconEvent): void => { | ||
| // Use head_v2 instead when gloas is configured, as head is deprecated post-gloas | ||
| if (event.type === EventType.head) { | ||
| const {message} = event; | ||
| const {slot, block, previousDutyDependentRoot, currentDutyDependentRoot} = message; | ||
|
|
@@ -101,6 +104,33 @@ export class ChainHeaderTracker { | |
| }); | ||
| } | ||
|
|
||
| if (event.type === EventType.headV2) { | ||
| const {data} = event.message; | ||
| const {slot, block, currentEpochDependentRoot, nextEpochDependentRoot} = data; | ||
| this.headBlockSlot = slot; | ||
| this.headBlockRoot = fromHex(block); | ||
|
|
||
| const headEventData = { | ||
| slot: this.headBlockSlot, | ||
| head: block, | ||
| previousDutyDependentRoot: currentEpochDependentRoot, | ||
| currentDutyDependentRoot: nextEpochDependentRoot, | ||
|
Comment on lines
+116
to
+117
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we refactor our head event data and add the shim to v1 event, the new semantics of head v2 are much nicer
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i'm glad to make that change, but maybe it is best to do it in another PR to keep this one readable. let me know if you agree and I will make an issue for this |
||
| }; | ||
|
|
||
| for (const fn of this.fns) { | ||
| fn(headEventData).catch((e) => this.logger.error("Error calling head event handler", e)); | ||
| } | ||
|
|
||
| this.emitter.emit(ValidatorEvent.chainHead, headEventData); | ||
|
|
||
| this.logger.verbose("Found new chain head", { | ||
| slot: slot, | ||
| head: block, | ||
| previousDuty: currentEpochDependentRoot, | ||
| currentDuty: nextEpochDependentRoot, | ||
| }); | ||
| } | ||
|
|
||
| if (event.type === EventType.executionPayloadAvailable) { | ||
| this.emitter.emit(ValidatorEvent.executionPayloadAvailable, event.message); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think reusing the same function in
importBlockandimportExecutionPayloadis a bad abstractionThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i will keep this in mind and address it once I handle the emissions properly