@lodestar/beacon-node 1.47.0-dev.beabd3a65c → 1.47.0-dev.c1d9395d5e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,7 @@ import {ExecutionPayloadStatus} from "../../execution/index.js";
24
24
  import {GossipType} from "../../network/index.js";
25
25
  import {CannotAcceptWorkReason, ReprocessRejectReason} from "../../network/processor/index.js";
26
26
  import {BackfillSyncMethod} from "../../sync/backfill/backfill.js";
27
- import {PendingBlockType} from "../../sync/types.js";
27
+ import {DroppedItemReason, PendingBlockType} from "../../sync/types.js";
28
28
  import {PeerSyncType, RangeSyncType} from "../../sync/utils/remoteSyncType.js";
29
29
  import {AllocSource} from "../../util/bufferPool.js";
30
30
  import {DataColumnReconstructionCode} from "../../util/dataColumns.js";
@@ -680,9 +680,15 @@ export function createLodestarMetrics(
680
680
  name: "lodestar_sync_unknown_block_downloaded_blocks_error_total",
681
681
  help: "Total number of downloaded blocks errors in UnknownBlockSync",
682
682
  }),
683
- removedBlocks: register.gauge({
683
+ removedBlocks: register.gauge<{reason: DroppedItemReason}>({
684
684
  name: "lodestar_sync_unknown_block_removed_blocks_total",
685
- help: "Total number of removed bad blocks in UnknownBlockSync",
685
+ help: "Total pending blocks dropped from BlockInputSync without completing, by reason",
686
+ labelNames: ["reason"],
687
+ }),
688
+ removedPayloads: register.gauge<{reason: DroppedItemReason}>({
689
+ name: "lodestar_sync_unknown_block_removed_payloads_total",
690
+ help: "Total pending payloads dropped from BlockInputSync without completing, by reason",
691
+ labelNames: ["reason"],
686
692
  }),
687
693
  elapsedTimeTillReceived: register.histogram({
688
694
  name: "lodestar_sync_unknown_block_elapsed_time_till_received",
package/src/sync/types.ts CHANGED
@@ -36,6 +36,39 @@ export enum PendingBlockInputStatus {
36
36
  processing = "processing",
37
37
  }
38
38
 
39
+ /**
40
+ * Why a pending item was dropped from BlockInputSync WITHOUT completing.
41
+ * The happy path — a successfully imported/processed block or payload — is removed by the import
42
+ * flow (onPayloadImported, processReadyBlock/processPayload success) and is deliberately NOT tracked
43
+ * here
44
+ */
45
+ export enum DroppedItemReason {
46
+ /** prune: item's own slot is below the finalized slot (routine cleanup), or a downloaded block landed at/below finality */
47
+ belowFinalized = "below_finalized",
48
+ /** prune: slot-less, unresolvable payload evicted after PRUNE_UNRESOLVED_SLOT_EPOCHS */
49
+ agedOut = "aged_out",
50
+ /** pruneSetToMax evicted the oldest entry because the cache hit its capacity (DoS guard) */
51
+ capacity = "capacity",
52
+ /** descendant removed because an ancestor was invalid/removed */
53
+ invalidParent = "invalid_parent",
54
+ /** transient execution-engine error removal (EXECUTION_ENGINE_ERROR) */
55
+ elError = "el_error",
56
+ /** the execution engine declared the item invalid (EXECUTION_ENGINE_INVALID) */
57
+ elInvalid = "el_invalid",
58
+ /** a block failed our own checks (not correct w.r.t. our chain) */
59
+ invalidBlock = "invalid_block",
60
+ /** a block declares a parent payload hash that conflicts with the parent's actual payload (gloas) */
61
+ invalidParentPayload = "invalid_parent_payload",
62
+ /** a payload envelope failed gossip verification (ENVELOPE_VERIFICATION_ERROR) */
63
+ invalidEnvelope = "invalid_envelope",
64
+ /** a payload had an invalid signature (INVALID_SIGNATURE) */
65
+ invalidSignature = "invalid_signature",
66
+ /** a block root was given up on after exhausting download attempts (data could not be fetched) */
67
+ unavailable = "unavailable",
68
+ /** an unexpected / unhandled error code led to removal */
69
+ unknown = "unknown",
70
+ }
71
+
39
72
  export enum PendingPayloadInputStatus {
40
73
  pending = "pending",
41
74
  fetching = "fetching",
@@ -1,7 +1,7 @@
1
1
  import {routes} from "@lodestar/api";
2
2
  import {ChainForkConfig} from "@lodestar/config";
3
3
  import {CheckpointWithHex} from "@lodestar/fork-choice";
4
- import {ForkSeq, isForkPostGloas} from "@lodestar/params";
4
+ import {ForkSeq, SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
5
5
  import {RequestError, RequestErrorCode} from "@lodestar/reqresp";
6
6
  import {computeTimeAtSlot} from "@lodestar/state-transition";
7
7
  import {RootHex, Slot, gloas} from "@lodestar/types";
@@ -25,6 +25,7 @@ import {MAX_CONCURRENT_REQUESTS} from "./constants.js";
25
25
  import {SyncOptions} from "./options.js";
26
26
  import {
27
27
  BlockInputSyncCacheItem,
28
+ DroppedItemReason,
28
29
  PayloadSyncCacheItem,
29
30
  PendingBlockInput,
30
31
  PendingBlockInputStatus,
@@ -48,6 +49,10 @@ import {getRateLimitedUntilMs} from "./utils/rateLimit.js";
48
49
  const MAX_ATTEMPTS_PER_BLOCK = 5;
49
50
  const MAX_KNOWN_BAD_BLOCKS = 500;
50
51
  const MAX_PENDING_BLOCKS = 100;
52
+ /**
53
+ * A pending payload without slot can only stay at most 2 epochs.
54
+ */
55
+ const PRUNE_UNRESOLVED_SLOT_EPOCHS = 2;
51
56
 
52
57
  type AdvancePendingBlockResult =
53
58
  | "ready"
@@ -255,7 +260,7 @@ export class BlockInputSync {
255
260
  parentRoot: missingDependency.parentRootHex,
256
261
  parentBlockHash: missingDependency.parentBlockHashHex,
257
262
  });
258
- this.removeAndDownScoreAllDescendants(pendingBlock);
263
+ this.removeAndDownScoreAllDescendants(pendingBlock, DroppedItemReason.invalidParentPayload);
259
264
  }
260
265
  return;
261
266
  }
@@ -295,28 +300,37 @@ export class BlockInputSync {
295
300
  // Use the actual finalized block slot, not computeStartSlotAtEpoch(checkpoint.epoch)
296
301
  // because the payload at the finalized block is not finalized
297
302
  const finalizedSlot = this.chain.forkChoice.getFinalizedBlock().slot;
303
+ const nowSec = Date.now() / 1000;
304
+ const maxSecInCache = (PRUNE_UNRESOLVED_SLOT_EPOCHS * SLOTS_PER_EPOCH * this.config.SLOT_DURATION_MS) / 1000;
298
305
 
299
- let deletedPayloads = 0;
306
+ let belowFinalizedPayloads = 0;
307
+ let agedOutPayloads = 0;
300
308
  let unresolvedSlotPayloads = 0;
309
+
301
310
  for (const [rootHex, payload] of this.pendingPayloads) {
302
311
  // Last-effort resolve for a slot-less PendingPayloadRootHex, and store it back for later passes.
303
312
  if (!isPendingPayloadInput(payload) && !isPendingPayloadEnvelope(payload) && payload.slot === undefined) {
304
313
  payload.slot = this.resolvePayloadSlot(rootHex);
314
+ if (payload.slot === undefined) {
315
+ // Still unresolvable: age it out so the MAX_PENDING_BLOCKS cap cannot stay saturated.
316
+ if (nowSec - payload.timeAddedSec > maxSecInCache) {
317
+ this.pendingPayloads.delete(rootHex);
318
+ agedOutPayloads++;
319
+ } else {
320
+ unresolvedSlotPayloads++;
321
+ this.logger.debug("BlockInputSync.pruneFinalized: pending payload with unresolved slot", {
322
+ root: rootHex,
323
+ finalizedSlot,
324
+ });
325
+ }
326
+ continue;
327
+ }
305
328
  }
306
329
 
307
330
  const slot = getPayloadSyncCacheItemSlot(payload);
308
- if (typeof slot !== "number") {
309
- unresolvedSlotPayloads++;
310
- this.logger.debug("BlockInputSync.pruneFinalized: pending payload with unresolved slot", {
311
- root: rootHex,
312
- finalizedSlot,
313
- });
314
- continue;
315
- }
316
-
317
- if (slot < finalizedSlot) {
331
+ if (typeof slot === "number" && slot < finalizedSlot) {
318
332
  this.pendingPayloads.delete(rootHex);
319
- deletedPayloads++;
333
+ belowFinalizedPayloads++;
320
334
  }
321
335
  }
322
336
 
@@ -329,14 +343,24 @@ export class BlockInputSync {
329
343
  }
330
344
  }
331
345
 
332
- if (deletedPayloads > 0 || deletedBlocks > 0) {
333
- this.metrics?.blockInputSync.removedBlocks.inc(deletedPayloads + deletedBlocks);
346
+ if (belowFinalizedPayloads > 0) {
347
+ this.metrics?.blockInputSync.removedPayloads.inc(
348
+ {reason: DroppedItemReason.belowFinalized},
349
+ belowFinalizedPayloads
350
+ );
351
+ }
352
+ if (agedOutPayloads > 0) {
353
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: DroppedItemReason.agedOut}, agedOutPayloads);
354
+ }
355
+ if (deletedBlocks > 0) {
356
+ this.metrics?.blockInputSync.removedBlocks.inc({reason: DroppedItemReason.belowFinalized}, deletedBlocks);
334
357
  }
335
358
 
336
359
  this.logger.debug("BlockInputSync.pruneFinalized dropped pre-finalized pending items", {
337
360
  finalizedSlot,
338
361
  finalizedRoot: checkpoint.rootHex,
339
- deletedPayloads,
362
+ belowFinalizedPayloads,
363
+ agedOutPayloads,
340
364
  deletedBlocks,
341
365
  unresolvedSlotPayloads,
342
366
  });
@@ -369,6 +393,7 @@ export class BlockInputSync {
369
393
  // Limit pending blocks to prevent DOS attacks that cause OOM
370
394
  const prunedItemCount = pruneSetToMax(this.pendingBlocks, this.maxPendingBlocks);
371
395
  if (prunedItemCount > 0) {
396
+ this.metrics?.blockInputSync.removedBlocks.inc({reason: DroppedItemReason.capacity}, prunedItemCount);
372
397
  this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingBlocks`);
373
398
  }
374
399
  return added;
@@ -403,6 +428,7 @@ export class BlockInputSync {
403
428
  // Limit pending blocks to prevent DOS attacks that cause OOM
404
429
  const prunedItemCount = pruneSetToMax(this.pendingBlocks, this.maxPendingBlocks);
405
430
  if (prunedItemCount > 0) {
431
+ this.metrics?.blockInputSync.removedBlocks.inc({reason: DroppedItemReason.capacity}, prunedItemCount);
406
432
  this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingBlocks`);
407
433
  }
408
434
  };
@@ -427,6 +453,14 @@ export class BlockInputSync {
427
453
  root: rootHex,
428
454
  peerIdStr: peerIdStr ?? "unknown peer",
429
455
  });
456
+ } else if (
457
+ // update a previously slot-less entry once a slot becomes available
458
+ slot !== undefined &&
459
+ !isPendingPayloadInput(pendingPayload) &&
460
+ !isPendingPayloadEnvelope(pendingPayload) &&
461
+ pendingPayload.slot === undefined
462
+ ) {
463
+ pendingPayload.slot = slot;
430
464
  }
431
465
 
432
466
  if (peerIdStr) {
@@ -435,6 +469,7 @@ export class BlockInputSync {
435
469
 
436
470
  const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks);
437
471
  if (prunedItemCount > 0) {
472
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: DroppedItemReason.capacity}, prunedItemCount);
438
473
  this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`);
439
474
  }
440
475
  return added;
@@ -488,6 +523,7 @@ export class BlockInputSync {
488
523
 
489
524
  const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks);
490
525
  if (prunedItemCount > 0) {
526
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: DroppedItemReason.capacity}, prunedItemCount);
491
527
  this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`);
492
528
  }
493
529
  };
@@ -582,7 +618,7 @@ export class BlockInputSync {
582
618
  parentRoot: missingDependency.parentRootHex,
583
619
  parentBlockHash: missingDependency.parentBlockHashHex,
584
620
  });
585
- this.removeAndDownScoreAllDescendants(pendingBlock);
621
+ this.removeAndDownScoreAllDescendants(pendingBlock, DroppedItemReason.invalidParentPayload);
586
622
  return "removed";
587
623
  }
588
624
  }
@@ -805,7 +841,7 @@ export class BlockInputSync {
805
841
  ...logCtx2,
806
842
  finalizedSlot,
807
843
  });
808
- this.removeAndDownScoreAllDescendants(block);
844
+ this.removeAndDownScoreAllDescendants(block, DroppedItemReason.belowFinalized);
809
845
  } else {
810
846
  this.onUnknownBlockRoot({rootHex: pending.blockInput.parentRootHex, source: BlockInputSource.byRoot});
811
847
  }
@@ -822,7 +858,7 @@ export class BlockInputSync {
822
858
 
823
859
  this.metrics?.blockInputSync.downloadedBlocksError.inc();
824
860
  this.logger.debug("Ignoring unknown block root after many failed downloads", logCtx, res.err);
825
- this.removeAndDownScoreAllDescendants(block);
861
+ this.removeAndDownScoreAllDescendants(block, DroppedItemReason.unavailable);
826
862
  }
827
863
  }
828
864
 
@@ -930,19 +966,19 @@ export class BlockInputSync {
930
966
  case BlockErrorCode.EXECUTION_ENGINE_ERROR:
931
967
  // Removing the block(s) without penalizing the peers, hoping for EL to
932
968
  // recover on a latter download + verify attempt.
933
- this.removeAllDescendants(pendingBlock);
969
+ this.removeAllDescendants(pendingBlock, DroppedItemReason.elError, DroppedItemReason.elError);
934
970
  break;
935
971
 
936
972
  case BlockErrorCode.EXECUTION_ENGINE_INVALID:
937
973
  // the peer served a bad block
938
974
  this.logger.debug("Execution engine rejected block from unknown parent sync", errorData, res.err);
939
- this.removeAndDownScoreAllDescendants(pendingBlock);
975
+ this.removeAndDownScoreAllDescendants(pendingBlock, DroppedItemReason.elInvalid);
940
976
  break;
941
977
 
942
978
  default:
943
979
  // Block is not correct with respect to our chain. Log error loudly
944
980
  this.logger.debug("Error processing block from unknown parent sync", errorData, res.err);
945
- this.removeAndDownScoreAllDescendants(pendingBlock);
981
+ this.removeAndDownScoreAllDescendants(pendingBlock, DroppedItemReason.invalidBlock);
946
982
  }
947
983
  }
948
984
 
@@ -1146,17 +1182,27 @@ export class BlockInputSync {
1146
1182
  break;
1147
1183
 
1148
1184
  case PayloadErrorCode.EXECUTION_ENGINE_INVALID:
1185
+ this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
1186
+ this.removePendingPayloadAndDescendants(rootHex, DroppedItemReason.elInvalid);
1187
+ break;
1188
+
1149
1189
  case PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR:
1190
+ this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
1191
+ this.removePendingPayloadAndDescendants(rootHex, DroppedItemReason.invalidEnvelope);
1192
+ break;
1193
+
1150
1194
  case PayloadErrorCode.INVALID_SIGNATURE:
1151
1195
  // TODO GLOAS: Decide how invalid payload inputs should eventually leave memory without
1152
1196
  // reintroducing envelope replacement / recreation flows.
1153
1197
  this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
1154
- this.removePendingPayloadAndDescendants(rootHex);
1198
+ this.removePendingPayloadAndDescendants(rootHex, DroppedItemReason.invalidSignature);
1155
1199
  break;
1156
1200
 
1157
1201
  default:
1158
1202
  this.logger.debug("Error processing payload from unknown sync", logCtx, res.err);
1159
- this.pendingPayloads.delete(rootHex);
1203
+ if (this.pendingPayloads.delete(rootHex)) {
1204
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: DroppedItemReason.unknown}, 1);
1205
+ }
1160
1206
  }
1161
1207
  return;
1162
1208
  }
@@ -1546,9 +1592,9 @@ export class BlockInputSync {
1546
1592
  * Downscore all peers that have referenced any of this bad blocks. May report peers multiple times if they have
1547
1593
  * referenced more than one bad block.
1548
1594
  */
1549
- private removeAndDownScoreAllDescendants(block: BlockInputSyncCacheItem): void {
1595
+ private removeAndDownScoreAllDescendants(block: BlockInputSyncCacheItem, headReason: DroppedItemReason): void {
1550
1596
  // Get all blocks that are a descendant of this one
1551
- const badPendingBlocks = this.removeAllDescendants(block);
1597
+ const badPendingBlocks = this.removeAllDescendants(block, headReason, DroppedItemReason.invalidParent);
1552
1598
  // just console log and do not penalize on pending/bad blocks for debugging
1553
1599
  // console.log("removeAndDownscoreAllDescendants", {block});
1554
1600
 
@@ -1575,18 +1621,22 @@ export class BlockInputSync {
1575
1621
  }
1576
1622
 
1577
1623
  // Once a parent payload is invalid, every descendant waiting on that payload lineage becomes unrecoverable too.
1578
- private removePendingPayloadAndDescendants(rootHex: RootHex): void {
1624
+ private removePendingPayloadAndDescendants(rootHex: RootHex, headReason: DroppedItemReason): void {
1579
1625
  // Keep PayloadEnvelopeInput resident in the seen cache. importBlock() owns that object and
1580
1626
  // later validation/finalization logic decides when it can leave memory.
1581
- this.pendingPayloads.delete(rootHex);
1627
+ if (this.pendingPayloads.delete(rootHex)) {
1628
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: headReason}, 1);
1629
+ }
1582
1630
 
1583
1631
  const badPendingBlocks = getAllDescendantBlocks(rootHex, this.pendingBlocks);
1584
- this.metrics?.blockInputSync.removedBlocks.inc(badPendingBlocks.length);
1585
-
1586
1632
  for (const block of badPendingBlocks) {
1587
1633
  const descendantRootHex = getBlockInputSyncCacheItemRootHex(block);
1588
- this.pendingBlocks.delete(descendantRootHex);
1589
- this.pendingPayloads.delete(descendantRootHex);
1634
+ if (this.pendingBlocks.delete(descendantRootHex)) {
1635
+ this.metrics?.blockInputSync.removedBlocks.inc({reason: DroppedItemReason.invalidParent}, 1);
1636
+ }
1637
+ if (this.pendingPayloads.delete(descendantRootHex)) {
1638
+ this.metrics?.blockInputSync.removedPayloads.inc({reason: DroppedItemReason.invalidParent}, 1);
1639
+ }
1590
1640
  this.chain.seenBlockInputCache.prune(descendantRootHex);
1591
1641
  this.logger.debug("Removing pending descendant after invalid parent payload", {
1592
1642
  slot: getBlockInputSyncCacheItemSlot(block),
@@ -1596,18 +1646,25 @@ export class BlockInputSync {
1596
1646
  }
1597
1647
  }
1598
1648
 
1599
- private removeAllDescendants(block: BlockInputSyncCacheItem): BlockInputSyncCacheItem[] {
1649
+ private removeAllDescendants(
1650
+ block: BlockInputSyncCacheItem,
1651
+ headReason: DroppedItemReason,
1652
+ descendantReason: DroppedItemReason = DroppedItemReason.invalidParent
1653
+ ): BlockInputSyncCacheItem[] {
1600
1654
  const rootHex = getBlockInputSyncCacheItemRootHex(block);
1601
1655
  const slot = getBlockInputSyncCacheItemSlot(block);
1602
- // Get all blocks that are a descendant of this one
1656
+ // Get all blocks that are a descendant of this one (index 0 is the head item itself)
1603
1657
  const badPendingBlocks = [block, ...getAllDescendantBlocks(rootHex, this.pendingBlocks)];
1604
1658
 
1605
- this.metrics?.blockInputSync.removedBlocks.inc(badPendingBlocks.length);
1606
-
1607
- for (const block of badPendingBlocks) {
1659
+ for (const [i, block] of badPendingBlocks.entries()) {
1608
1660
  const rootHex = getBlockInputSyncCacheItemRootHex(block);
1609
- this.pendingBlocks.delete(rootHex);
1610
- this.pendingPayloads.delete(rootHex);
1661
+ const reason = i === 0 ? headReason : descendantReason;
1662
+ if (this.pendingBlocks.delete(rootHex)) {
1663
+ this.metrics?.blockInputSync.removedBlocks.inc({reason}, 1);
1664
+ }
1665
+ if (this.pendingPayloads.delete(rootHex)) {
1666
+ this.metrics?.blockInputSync.removedPayloads.inc({reason}, 1);
1667
+ }
1611
1668
  this.chain.seenBlockInputCache.prune(rootHex);
1612
1669
  // Keep PayloadEnvelopeInput resident in the seen cache for consistency with the
1613
1670
  // importBlock()-owned lifecycle.