-
-
Notifications
You must be signed in to change notification settings - Fork 348
/
Copy pathprepareNextSlot.ts
201 lines (185 loc) Β· 9.26 KB
/
prepareNextSlot.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import {computeEpochAtSlot, isExecutionStateType, computeTimeAtSlot} from "@lodestar/state-transition";
import {ChainForkConfig} from "@lodestar/config";
import {ForkSeq, SLOTS_PER_EPOCH, ForkExecution} from "@lodestar/params";
import {Slot} from "@lodestar/types";
import {Logger, sleep, fromHex, isErrorAborted} from "@lodestar/utils";
import {routes} from "@lodestar/api";
import {GENESIS_SLOT, ZERO_HASH_HEX} from "../constants/constants.js";
import {Metrics} from "../metrics/index.js";
import {TransitionConfigurationV1} from "../execution/engine/interface.js";
import {ClockEvent} from "../util/clock.js";
import {isQueueErrorAborted} from "../util/queue/index.js";
import {prepareExecutionPayload, getPayloadAttributesForSSE} from "./produceBlock/produceBlockBody.js";
import {IBeaconChain} from "./interface.js";
import {RegenCaller} from "./regen/index.js";
/* With 12s slot times, this scheduler will run 4s before the start of each slot (`12 / 3 = 4`). */
export const SCHEDULER_LOOKAHEAD_FACTOR = 3;
/* We don't want to do more epoch transition than this */
const PREPARE_EPOCH_LIMIT = 1;
/**
* At Bellatrix, if we are responsible for proposing in next slot, we want to prepare payload
* 4s (1/3 slot) before the start of next slot
*
* For all forks, when clock is 1/3 slot before an epoch, we want to prepare for the next epoch
* transition from our head so that:
* + validators vote for block head on time through attestation
* + validators propose blocks on time
* + For Bellatrix, to compute proposers of next epoch so that we can prepare new payloads
*
*/
export class PrepareNextSlotScheduler {
private transitionConfig: TransitionConfigurationV1 | null = null;
constructor(
private readonly chain: IBeaconChain,
private readonly config: ChainForkConfig,
private readonly metrics: Metrics | null,
private readonly logger: Logger,
private readonly signal: AbortSignal
) {
this.chain.clock.on(ClockEvent.slot, this.prepareForNextSlot);
this.signal.addEventListener(
"abort",
() => {
this.chain.clock.off(ClockEvent.slot, this.prepareForNextSlot);
},
{once: true}
);
}
/**
* Use clockSlot instead of clockEpoch to schedule the task at more exact time.
*/
prepareForNextSlot = async (clockSlot: Slot): Promise<void> => {
const prepareSlot = clockSlot + 1;
const prepareEpoch = computeEpochAtSlot(prepareSlot);
const nextEpoch = computeEpochAtSlot(clockSlot) + 1;
const isEpochTransition = prepareEpoch === nextEpoch;
const fork = this.config.getForkName(prepareSlot);
// Early return if we are pre-genesis
// or we are pre-bellatrix and this is not an epoch transition
if (prepareSlot <= GENESIS_SLOT || (ForkSeq[fork] < ForkSeq.bellatrix && !isEpochTransition)) {
return;
}
try {
// At 1/3 slot time before the next slot, we either prepare payload or precompute
// epoch transition
const slotMs = this.config.SECONDS_PER_SLOT * 1000;
await sleep(slotMs - slotMs / SCHEDULER_LOOKAHEAD_FACTOR, this.signal);
// calling updateHead() here before we produce a block to reduce reorg possibility
const {slot: headSlot, blockRoot: headRoot} = this.chain.recomputeForkChoiceHead();
// PS: previously this was comparing slots, but that gave no leway on the skipped
// slots on epoch bounday. Making it more fluid.
if (prepareSlot - headSlot > PREPARE_EPOCH_LIMIT * SLOTS_PER_EPOCH) {
this.metrics?.precomputeNextEpochTransition.count.inc({result: "skip"}, 1);
this.logger.debug("Skipping PrepareNextSlotScheduler - head slot is too behind current slot", {
nextEpoch,
headSlot,
clockSlot,
});
// It's important to still do this to get through Holesky unfinality time of low resouce nodes
await this.prunePerSlot(clockSlot);
return;
}
this.logger.verbose("Running prepareForNextSlot", {
nextEpoch,
prepareSlot,
headSlot,
headRoot,
isEpochTransition,
});
// No need to wait for this or the clock drift
// Pre Bellatrix: we only do precompute state transition for the last slot of epoch
// For Bellatrix, we always do the `processSlots()` to prepare payload for the next slot
const prepareState = await this.chain.regen.getBlockSlotState(
headRoot,
prepareSlot,
{dontTransferCache: true},
RegenCaller.precomputeEpoch
);
// assuming there is no reorg, it caches the checkpoint state & helps avoid doing a full state transition in the next slot
// + when gossip block comes, we need to validate and run state transition
// + if next slot is a skipped slot, it'd help getting target checkpoint state faster to validate attestations
if (isEpochTransition) {
this.metrics?.precomputeNextEpochTransition.count.inc({result: "success"}, 1);
const previousHits = this.chain.regen.updatePreComputedCheckpoint(headRoot, nextEpoch);
if (previousHits === 0) {
this.metrics?.precomputeNextEpochTransition.waste.inc();
}
this.metrics?.precomputeNextEpochTransition.hits.set(previousHits ?? 0);
this.logger.verbose("Completed PrepareNextSlotScheduler epoch transition", {
nextEpoch,
headSlot,
prepareSlot,
});
}
if (isExecutionStateType(prepareState)) {
const proposerIndex = prepareState.epochCtx.getBeaconProposer(prepareSlot);
const feeRecipient = this.chain.beaconProposerCache.get(proposerIndex);
if (feeRecipient) {
// Update the builder status, if enabled shoot an api call to check status
this.chain.updateBuilderStatus(clockSlot);
if (this.chain.executionBuilder?.status) {
this.chain.executionBuilder.checkStatus().catch((e) => {
this.logger.error("Builder disabled as the check status api failed", {prepareSlot}, e as Error);
});
}
const preparationTime =
computeTimeAtSlot(this.config, prepareSlot, this.chain.genesisTime) - Date.now() / 1000;
this.metrics?.blockPayload.payloadAdvancePrepTime.observe(preparationTime);
const safeBlockHash = this.chain.forkChoice.getJustifiedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX;
const finalizedBlockHash =
this.chain.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX;
// awaiting here instead of throwing an async call because there is no other task
// left for scheduler and this gives nice sematics to catch and log errors in the
// try/catch wrapper here.
await prepareExecutionPayload(
this.chain,
this.logger,
fork as ForkExecution, // State is of execution type
fromHex(headRoot),
safeBlockHash,
finalizedBlockHash,
prepareState,
feeRecipient
);
this.logger.verbose("PrepareNextSlotScheduler prepared new payload", {
prepareSlot,
proposerIndex,
feeRecipient,
});
}
// If emitPayloadAttributes is true emit a SSE payloadAttributes event
if (this.chain.opts.emitPayloadAttributes === true) {
const data = await getPayloadAttributesForSSE(fork as ForkExecution, this.chain, {
prepareState,
prepareSlot,
parentBlockRoot: fromHex(headRoot),
// The likely consumers of this API are builders and will anyway ignore the
// feeRecipient, so just pass zero hash for now till a real use case arises
feeRecipient: "0x0000000000000000000000000000000000000000000000000000000000000000",
});
this.chain.emitter.emit(routes.events.EventType.payloadAttributes, {data, version: fork});
}
}
// do this after all as it's the lowest priority task
await this.prunePerSlot(clockSlot);
} catch (e) {
if (!isErrorAborted(e) && !isQueueErrorAborted(e)) {
this.metrics?.precomputeNextEpochTransition.count.inc({result: "error"}, 1);
this.logger.error("Failed to run prepareForNextSlot", {nextEpoch, isEpochTransition, prepareSlot}, e as Error);
}
}
};
/**
* Pruning at the last 1/3 slot of first slot of epoch is the safest time because all epoch transitions already use the checkpoint states cached
* one down side of this is when `inMemoryEpochs = 0` and gossip block hasn't come yet then we have to reload state we added 2/3 slot ago
* However, it's not likely `inMemoryEpochs` is configured as 0, and this scenario rarely happen
* since we only use `inMemoryEpochs = 0` for testing, if it happens it's a good thing because it helps us test the reload flow
*/
private prunePerSlot = async (clockSlot: Slot): Promise<void> => {
// a contabo vpss can have 10-12 holesky epoch transitions per epoch when syncing, stronger node may have more
// it's better to prune at the last 1/3 of every slot in order not to cache a lot of checkpoint states
// at synced time, it's likely we only prune at the 1st slot of epoch, all other prunes are no-op
const pruneCount = await this.chain.regen.pruneCheckpointStateCache();
this.logger.verbose("Pruned checkpoint state cache", {clockSlot, pruneCount});
};
}