envio 3.3.0-alpha.1 → 3.3.0-alpha.10

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.
Files changed (136) hide show
  1. package/evm.schema.json +10 -0
  2. package/index.d.ts +45 -25
  3. package/package.json +7 -7
  4. package/src/Address.res +5 -2
  5. package/src/Address.res.mjs +3 -1
  6. package/src/Api.res +1 -1
  7. package/src/Api.res.mjs +1 -1
  8. package/src/Batch.res +0 -6
  9. package/src/Batch.res.mjs +1 -12
  10. package/src/BatchProcessing.res +7 -12
  11. package/src/BatchProcessing.res.mjs +8 -7
  12. package/src/Bin.res +3 -0
  13. package/src/Bin.res.mjs +4 -0
  14. package/src/ChainFetching.res +43 -9
  15. package/src/ChainFetching.res.mjs +34 -15
  16. package/src/ChainMetadata.res +1 -11
  17. package/src/ChainMetadata.res.mjs +1 -10
  18. package/src/ChainState.res +605 -151
  19. package/src/ChainState.res.mjs +427 -104
  20. package/src/ChainState.resi +72 -4
  21. package/src/Config.res +61 -33
  22. package/src/Config.res.mjs +50 -10
  23. package/src/ContractRegisterContext.res +2 -12
  24. package/src/ContractRegisterContext.res.mjs +3 -5
  25. package/src/Core.res +14 -3
  26. package/src/CrossChainState.res +113 -89
  27. package/src/CrossChainState.res.mjs +64 -60
  28. package/src/CrossChainState.resi +1 -1
  29. package/src/Ecosystem.res +5 -6
  30. package/src/Ecosystem.res.mjs +3 -3
  31. package/src/Envio.res +17 -11
  32. package/src/EnvioGlobal.res +53 -0
  33. package/src/EnvioGlobal.res.mjs +31 -0
  34. package/src/EventConfigBuilder.res +177 -88
  35. package/src/EventConfigBuilder.res.mjs +99 -42
  36. package/src/EventProcessing.res +74 -28
  37. package/src/EventProcessing.res.mjs +55 -29
  38. package/src/ExitOnCaughtUp.res +10 -2
  39. package/src/ExitOnCaughtUp.res.mjs +10 -1
  40. package/src/FetchState.res +544 -356
  41. package/src/FetchState.res.mjs +412 -471
  42. package/src/HandlerLoader.res +8 -104
  43. package/src/HandlerLoader.res.mjs +2 -81
  44. package/src/HandlerRegister.res +518 -144
  45. package/src/HandlerRegister.res.mjs +285 -133
  46. package/src/HandlerRegister.resi +24 -8
  47. package/src/IndexerState.res +10 -3
  48. package/src/IndexerState.res.mjs +11 -4
  49. package/src/IndexerState.resi +2 -1
  50. package/src/IndexingAddresses.res +108 -0
  51. package/src/IndexingAddresses.res.mjs +101 -0
  52. package/src/IndexingAddresses.resi +34 -0
  53. package/src/Internal.res +155 -46
  54. package/src/Internal.res.mjs +30 -2
  55. package/src/LoadLayer.res +5 -5
  56. package/src/LoadLayer.res.mjs +6 -6
  57. package/src/LogSelection.res +102 -165
  58. package/src/LogSelection.res.mjs +101 -116
  59. package/src/Main.res +71 -236
  60. package/src/Main.res.mjs +60 -145
  61. package/src/Metrics.res +74 -0
  62. package/src/Metrics.res.mjs +75 -0
  63. package/src/PgStorage.res +4 -4
  64. package/src/PgStorage.res.mjs +5 -5
  65. package/src/Prometheus.res +10 -63
  66. package/src/Prometheus.res.mjs +124 -171
  67. package/src/PruneStaleHistory.res +2 -2
  68. package/src/PruneStaleHistory.res.mjs +3 -3
  69. package/src/RawEvent.res +7 -2
  70. package/src/RawEvent.res.mjs +4 -4
  71. package/src/Rollback.res +3 -3
  72. package/src/Rollback.res.mjs +4 -4
  73. package/src/RollbackCommit.res +4 -1
  74. package/src/RollbackCommit.res.mjs +3 -2
  75. package/src/SimulateDeadInputTracker.res +85 -0
  76. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  77. package/src/SimulateDeadInputTracker.resi +12 -0
  78. package/src/SimulateItems.res +91 -23
  79. package/src/SimulateItems.res.mjs +55 -32
  80. package/src/TestIndexer.res +54 -40
  81. package/src/TestIndexer.res.mjs +37 -30
  82. package/src/bindings/Performance.res +7 -0
  83. package/src/bindings/Performance.res.mjs +21 -0
  84. package/src/bindings/Performance.resi +7 -0
  85. package/src/bindings/Viem.res +0 -41
  86. package/src/bindings/Viem.res.mjs +1 -43
  87. package/src/sources/BlockStore.res +46 -0
  88. package/src/sources/BlockStore.res.mjs +24 -0
  89. package/src/sources/EventRouter.res +36 -33
  90. package/src/sources/EventRouter.res.mjs +13 -14
  91. package/src/sources/Evm.res +83 -72
  92. package/src/sources/Evm.res.mjs +53 -69
  93. package/src/sources/EvmChain.res +18 -20
  94. package/src/sources/EvmChain.res.mjs +15 -15
  95. package/src/sources/EvmRpcClient.res +74 -5
  96. package/src/sources/EvmRpcClient.res.mjs +13 -4
  97. package/src/sources/FieldMask.res +39 -0
  98. package/src/sources/FieldMask.res.mjs +42 -0
  99. package/src/sources/Fuel.res +9 -6
  100. package/src/sources/Fuel.res.mjs +5 -10
  101. package/src/sources/HyperFuelSource.res +46 -50
  102. package/src/sources/HyperFuelSource.res.mjs +64 -54
  103. package/src/sources/HyperSync.res +39 -7
  104. package/src/sources/HyperSync.res.mjs +57 -37
  105. package/src/sources/HyperSync.resi +12 -2
  106. package/src/sources/HyperSyncClient.res +22 -111
  107. package/src/sources/HyperSyncClient.res.mjs +2 -30
  108. package/src/sources/HyperSyncHeightStream.res +1 -8
  109. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  110. package/src/sources/HyperSyncSource.res +94 -107
  111. package/src/sources/HyperSyncSource.res.mjs +82 -86
  112. package/src/sources/Rpc.res +15 -47
  113. package/src/sources/Rpc.res.mjs +25 -56
  114. package/src/sources/RpcSource.res +320 -467
  115. package/src/sources/RpcSource.res.mjs +268 -385
  116. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  117. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  118. package/src/sources/SimulateSource.res +10 -4
  119. package/src/sources/SimulateSource.res.mjs +16 -6
  120. package/src/sources/Source.res +34 -6
  121. package/src/sources/SourceManager.res +77 -19
  122. package/src/sources/SourceManager.res.mjs +63 -21
  123. package/src/sources/SourceManager.resi +10 -0
  124. package/src/sources/Svm.res +31 -16
  125. package/src/sources/Svm.res.mjs +36 -13
  126. package/src/sources/SvmHyperSyncClient.res +5 -32
  127. package/src/sources/SvmHyperSyncSource.res +159 -134
  128. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  129. package/src/sources/TransactionStore.res +49 -0
  130. package/src/sources/TransactionStore.res.mjs +30 -0
  131. package/src/tui/Tui.res +13 -16
  132. package/src/tui/Tui.res.mjs +12 -14
  133. package/svm.schema.json +49 -37
  134. package/src/bindings/Hrtime.res +0 -58
  135. package/src/bindings/Hrtime.res.mjs +0 -90
  136. package/src/bindings/Hrtime.resi +0 -30
@@ -1,11 +1,5 @@
1
- type contractConfig = {startBlock: option<int>}
2
-
3
1
  type indexingAddress = Internal.indexingContract
4
2
 
5
- let deriveEffectiveStartBlock = (~registrationBlock: int, ~contractStartBlock: option<int>) => {
6
- Pervasives.max(Pervasives.max(registrationBlock, 0), contractStartBlock->Option.getOr(0))
7
- }
8
-
9
3
  type blockNumberAndTimestamp = {
10
4
  blockNumber: int,
11
5
  blockTimestamp: int,
@@ -13,15 +7,21 @@ type blockNumberAndTimestamp = {
13
7
 
14
8
  type blockNumberAndLogIndex = {blockNumber: int, logIndex: int}
15
9
 
16
- type selection = {eventConfigs: array<Internal.eventConfig>, dependsOnAddresses: bool}
10
+ type selection = {
11
+ onEventRegistrations: array<Internal.onEventRegistration>,
12
+ dependsOnAddresses: bool,
13
+ }
17
14
 
18
15
  type pendingQuery = {
19
16
  fromBlock: int,
20
17
  toBlock: option<int>,
21
18
  isChunk: bool,
22
- // Estimated items this in-flight query will add, carried from the query so the
23
- // shared buffer budget can account for what's already being fetched.
24
- estResponseSize: float,
19
+ // Items this in-flight query is targeting (server maxNumLogs-style cap).
20
+ itemsTarget: int,
21
+ // Estimated items this in-flight query will actually return (no headroom),
22
+ // carried from the query so the shared buffer budget can account for what's
23
+ // already being fetched without the cap's safety margin inflating it.
24
+ itemsEst: int,
25
25
  // Stores latestFetchedBlock when query completes. Only needed to persist
26
26
  // timestamp while earlier queries are still pending before updating
27
27
  // the partition's latestFetchedBlock.
@@ -65,38 +65,51 @@ type query = {
65
65
  fromBlock: int,
66
66
  toBlock: option<int>,
67
67
  isChunk: bool,
68
- // Estimated number of items the query will return, from the partition's
69
- // density and the query's block range. Used to admit queries against the
70
- // shared buffer budget.
71
- estResponseSize: float,
72
- // Owning chain and the chain progress % at the query's fromBlock. Set by the
73
- // cross-chain scheduler so candidate queries can be pooled and ordered
74
- // (furthest-behind first) without allocating a side tuple per query.
75
- mutable chainId: int,
76
- mutable progress: float,
68
+ // Items this query targets: the server-side maxNumLogs-style cap, sized
69
+ // with headroom (chunkItemsMultiplier) so a denser-than-expected range
70
+ // doesn't truncate the response.
71
+ itemsTarget: int,
72
+ // Expected items without headroom: density × the query's block range for a
73
+ // known-density partition, the query's budget share otherwise. This is the
74
+ // unit the chain's per-tick budget is reserved/consumed in reserving the
75
+ // headroomed cap instead would throttle the pipeline by the safety margin.
76
+ itemsEst: int,
77
77
  selection: selection,
78
78
  addressesByContractName: dict<array<Address.t>>,
79
- indexingAddresses: dict<indexingAddress>,
80
79
  }
81
80
 
82
- // Default estimate for a query whose partition hasn't responded yet, so the
83
- // shared budget still accounts for unknown queries instead of treating them as
84
- // free.
85
- let defaultEstResponseSize = 10_000.
86
-
87
- // Estimated items a query will return, from the partition's event density
88
- // (items/block derived from its last response) and the query's block range.
89
- // toBlock None is the open-ended tail, capped at maxQueryBlockNumber. A partition
90
- // that responded with no items has density 0, so its queries cost 0 — correct,
91
- // they don't fill the buffer. Only a partition that has never responded
92
- // (prevQueryRange 0) has no signal, so it falls back to defaultEstResponseSize.
93
- let calculateEstResponseSize = (p: partition, ~fromBlock, ~toBlock, ~maxQueryBlockNumber) =>
94
- if p.prevQueryRange > 0 {
95
- let density = p.prevRangeSize->Int.toFloat /. p.prevQueryRange->Int.toFloat
96
- (toBlock->Option.getOr(maxQueryBlockNumber) - fromBlock + 1)->Int.toFloat *. density
97
- } else {
98
- defaultEstResponseSize
99
- }
81
+ // Invert addressesByContractName into address→contractName for log-ownership
82
+ // routing. 1:1 today (each address belongs to one contract), so no key
83
+ // collisions. Memoized on the addressesByContractName object so a partition's
84
+ // many responses share one derivation and a large factory never rebuilds the
85
+ // whole index; sound because the dict is immutable after construction (every
86
+ // mutation produces a new dict).
87
+ let deriveContractNameByAddress: dict<array<Address.t>> => dict<
88
+ string,
89
+ > = Utils.WeakMap.memoize(addressesByContractName => {
90
+ let result = Dict.make()
91
+ addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
92
+ for i in 0 to addresses->Array.length - 1 {
93
+ result->Dict.set(addresses->Array.getUnsafe(i)->Address.toString, contractName)
94
+ }
95
+ })
96
+ result
97
+ })
98
+
99
+ // itemsTarget for a query over [fromBlock, toBlock] at the given event density
100
+ // (items/block). toBlock None is the open-ended tail, capped at
101
+ // chainTargetBlock — the soft per-tick horizon the owning chain wants to reach
102
+ // (see getNextQuery).
103
+ let densityItemsTarget = (~density, ~fromBlock, ~toBlock, ~chainTargetBlock) => {
104
+ // Floor at 1: the reservation must equal the server-side cap SourceManager
105
+ // sends, and a 0 cap would ask the backend for nothing.
106
+ Pervasives.max(
107
+ 1,
108
+ ((toBlock->Option.getOr(chainTargetBlock) - fromBlock + 1)->Int.toFloat *. density)
109
+ ->Math.ceil
110
+ ->Float.toInt,
111
+ )
112
+ }
100
113
 
101
114
  // Calculate the chunk range from history using min-of-last-3-ranges heuristic
102
115
  let getMinHistoryRange = (p: partition) => {
@@ -106,6 +119,15 @@ let getMinHistoryRange = (p: partition) => {
106
119
  }
107
120
  }
108
121
 
122
+ // Density (items/block) from the last response, trusted only after two
123
+ // responses — a single sample is too noisy to size the next query by.
124
+ let getTrustedDensity = (p: partition) => {
125
+ switch (p.prevQueryRange, p.prevPrevQueryRange) {
126
+ | (0, _) | (_, 0) => None
127
+ | (prevQueryRange, _) => Some(p.prevRangeSize->Int.toFloat /. prevQueryRange->Int.toFloat)
128
+ }
129
+ }
130
+
109
131
  let getMinQueryRange = (partitions: array<partition>) => {
110
132
  let min = ref(0)
111
133
  for i in 0 to partitions->Array.length - 1 {
@@ -185,6 +207,12 @@ module OptimizedPartitions = {
185
207
  let newId = nextPartitionIndexRef.contents->Int.toString
186
208
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
187
209
  let minRange = getMinQueryRange([p1, p2])
210
+ // The merged partition indexes both parents' addresses, so its expected
211
+ // event rate is the sum of their densities. Parents without a trusted
212
+ // density contribute 0; if none has one, prevRangeSize stays 0 and the
213
+ // partition probes for a fresh signal instead of chunking.
214
+ let inheritedDensity =
215
+ p1->getTrustedDensity->Option.getOr(0.) +. p2->getTrustedDensity->Option.getOr(0.)
188
216
  {
189
217
  id: newId,
190
218
  dynamicContract: Some(contractName),
@@ -195,7 +223,7 @@ module OptimizedPartitions = {
195
223
  mutPendingQueries: [],
196
224
  prevQueryRange: minRange,
197
225
  prevPrevQueryRange: minRange,
198
- prevRangeSize: 0,
226
+ prevRangeSize: (inheritedDensity *. minRange->Int.toFloat)->Math.ceil->Float.toInt,
199
227
  latestBlockRangeUpdateBlock: 0,
200
228
  }
201
229
  }
@@ -453,15 +481,21 @@ module OptimizedPartitions = {
453
481
  switch query.toBlock {
454
482
  | None => latestFetchedBlock.blockNumber < knownHeight - 10 // Don't update block range when very close to the head
455
483
  | Some(queryToBlock) =>
456
- // Update on partial response (direct capacity evidence),
457
- // or when the query's intended range covers at least the partition's
458
- // current chunk range meaning it was a capacity-based split chunk,
459
- // not a small gap-fill whose toBlock is an artificial boundary.
460
- latestFetchedBlock.blockNumber < queryToBlock ||
484
+ if latestFetchedBlock.blockNumber < queryToBlock {
485
+ // Partial response is direct capacity evidence unless it was
486
+ // truncated by our own itemsTarget cap: that reflects the
487
+ // reservation we asked for, not what the server could return.
488
+ itemsCount < query.itemsTarget
489
+ } else {
490
+ // A full response updates only when the query's intended range
491
+ // covers at least the partition's current chunk range — meaning it
492
+ // was a capacity-based split chunk, not a small gap-fill whose
493
+ // toBlock is an artificial boundary.
461
494
  switch getMinHistoryRange(p) {
462
495
  | None => false // Chunking not active yet, don't update
463
496
  | Some(minHistoryRange) => queryToBlock - query.fromBlock + 1 >= minHistoryRange
464
497
  }
498
+ }
465
499
  }
466
500
  let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
467
501
  let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
@@ -519,10 +553,8 @@ type t = {
519
553
  startBlock: int,
520
554
  endBlock: option<int>,
521
555
  normalSelection: selection,
522
- // By address
523
- indexingAddresses: dict<indexingAddress>,
524
556
  // By contract name
525
- contractConfigs: dict<contractConfig>,
557
+ contractConfigs: dict<IndexingAddresses.contractConfig>,
526
558
  // Not used for logic - only metadata
527
559
  chainId: int,
528
560
  // The block number of the latest block which was added to the queue
@@ -536,10 +568,10 @@ type t = {
536
568
  // Buffer of items ordered from earliest to latest
537
569
  buffer: array<Internal.item>,
538
570
  // Caps how far ahead onBlock items are pre-generated (set to 2x the batch
539
- // size). Fetch depth is bounded separately by getNextQuery's itemBudget, the
540
- // chain's per-tick slice of the indexer-wide pool.
571
+ // size). Event fetch depth is bounded separately, by CrossChainState's
572
+ // cross-chain admission against the indexer-wide buffer pool.
541
573
  maxOnBlockBufferSize: int,
542
- onBlockConfigs: array<Internal.onBlockConfig>,
574
+ onBlockRegistrations: array<Internal.onBlockRegistration>,
543
575
  knownHeight: int,
544
576
  firstEventBlock: option<int>,
545
577
  }
@@ -575,6 +607,25 @@ let bufferBlock = ({optimizedPartitions, latestOnBlockBlockNumber}: t) => {
575
607
  }
576
608
  }
577
609
 
610
+ // Number of buffered items at or below the ready frontier (processable now,
611
+ // i.e. not stuck behind a gap from a lagging partition or out-of-order chunk).
612
+ // The buffer is kept sorted, so binary-search the frontier in O(log n).
613
+ let bufferReadyCount = (fetchState: t) => {
614
+ let frontier = fetchState->bufferBlockNumber
615
+ let buffer = fetchState.buffer
616
+ let lo = ref(0)
617
+ let hi = ref(buffer->Array.length)
618
+ while lo.contents < hi.contents {
619
+ let mid = (lo.contents + hi.contents) / 2
620
+ if buffer->Array.getUnsafe(mid)->Internal.getItemBlockNumber <= frontier {
621
+ lo := mid + 1
622
+ } else {
623
+ hi := mid
624
+ }
625
+ }
626
+ lo.contents
627
+ }
628
+
578
629
  /*
579
630
  Comparitor for two events from the same chain. No need for chain id or timestamp
580
631
  */
@@ -590,15 +641,13 @@ let compareBufferItem = (a: Internal.item, b: Internal.item) => {
590
641
  // Some big number which should be bigger than any log index
591
642
  let blockItemLogIndex = 16777216
592
643
 
593
- let numAddresses = fetchState => fetchState.indexingAddresses->Utils.Dict.size
594
-
595
644
  // Appends Block items produced by the onBlock handlers for every block in
596
645
  // (fromBlock, maxBlockNumber] into mutItems and returns the new
597
646
  // latestOnBlockBlockNumber pointer. maxOnBlockBufferSize bounds how many items
598
647
  // are generated at once to prevent OOM.
599
648
  let appendOnBlockItems = (
600
649
  ~mutItems: array<Internal.item>,
601
- ~onBlockConfigs: array<Internal.onBlockConfig>,
650
+ ~onBlockRegistrations: array<Internal.onBlockRegistration>,
602
651
  ~indexerStartBlock,
603
652
  ~fromBlock,
604
653
  ~maxBlockNumber,
@@ -618,27 +667,27 @@ let appendOnBlockItems = (
618
667
  let blockNumber = latestOnBlockBlockNumber.contents + 1
619
668
  latestOnBlockBlockNumber := blockNumber
620
669
 
621
- for configIdx in 0 to onBlockConfigs->Array.length - 1 {
622
- let onBlockConfig = onBlockConfigs->Array.getUnsafe(configIdx)
670
+ for configIdx in 0 to onBlockRegistrations->Array.length - 1 {
671
+ let onBlockRegistration = onBlockRegistrations->Array.getUnsafe(configIdx)
623
672
 
624
- let handlerStartBlock = switch onBlockConfig.startBlock {
673
+ let handlerStartBlock = switch onBlockRegistration.startBlock {
625
674
  | Some(startBlock) => startBlock
626
675
  | None => indexerStartBlock
627
676
  }
628
677
 
629
678
  if (
630
679
  blockNumber >= handlerStartBlock &&
631
- switch onBlockConfig.endBlock {
680
+ switch onBlockRegistration.endBlock {
632
681
  | Some(endBlock) => blockNumber <= endBlock
633
682
  | None => true
634
683
  } &&
635
- (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockConfig.interval) === 0
684
+ (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockRegistration.interval) === 0
636
685
  ) {
637
686
  mutItems->Array.push(
638
687
  Block({
639
- onBlockConfig,
688
+ onBlockRegistration,
640
689
  blockNumber,
641
- logIndex: blockItemLogIndex + onBlockConfig.index,
690
+ logIndex: blockItemLogIndex + onBlockRegistration.index,
642
691
  }),
643
692
  )
644
693
  newItemsCounter := newItemsCounter.contents + 1
@@ -656,16 +705,15 @@ Runs partition optimization when partitions change.
656
705
  let updateInternal = (
657
706
  fetchState: t,
658
707
  ~optimizedPartitions=fetchState.optimizedPartitions,
659
- ~indexingAddresses=fetchState.indexingAddresses,
660
708
  ~mutItems=?,
661
709
  ~blockLag=fetchState.blockLag,
662
710
  ~knownHeight=fetchState.knownHeight,
663
711
  ): t => {
664
712
  let mutItemsRef = ref(mutItems)
665
713
 
666
- let latestOnBlockBlockNumber = switch fetchState.onBlockConfigs {
714
+ let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
667
715
  | [] => knownHeight
668
- | onBlockConfigs => {
716
+ | onBlockRegistrations => {
669
717
  // Calculate the max block number we are going to create items for
670
718
  // Use maxOnBlockBufferSize to get the last target item in the buffer
671
719
  //
@@ -693,7 +741,7 @@ let updateInternal = (
693
741
 
694
742
  appendOnBlockItems(
695
743
  ~mutItems,
696
- ~onBlockConfigs,
744
+ ~onBlockRegistrations,
697
745
  ~indexerStartBlock=fetchState.startBlock,
698
746
  ~fromBlock=fetchState.latestOnBlockBlockNumber,
699
747
  ~maxBlockNumber,
@@ -708,11 +756,10 @@ let updateInternal = (
708
756
  contractConfigs: fetchState.contractConfigs,
709
757
  normalSelection: fetchState.normalSelection,
710
758
  chainId: fetchState.chainId,
711
- onBlockConfigs: fetchState.onBlockConfigs,
759
+ onBlockRegistrations: fetchState.onBlockRegistrations,
712
760
  maxOnBlockBufferSize: fetchState.maxOnBlockBufferSize,
713
761
  optimizedPartitions,
714
762
  latestOnBlockBlockNumber,
715
- indexingAddresses,
716
763
  blockLag,
717
764
  knownHeight,
718
765
  buffer: switch mutItemsRef.contents {
@@ -740,12 +787,6 @@ let updateInternal = (
740
787
  ~blockNumber=updatedFetchState->bufferBlockNumber,
741
788
  ~chainId=fetchState.chainId,
742
789
  )
743
- if indexingAddresses !== fetchState.indexingAddresses {
744
- Prometheus.IndexingAddresses.set(
745
- ~addressesCount=updatedFetchState->numAddresses,
746
- ~chainId=fetchState.chainId,
747
- )
748
- }
749
790
 
750
791
  updatedFetchState
751
792
  }
@@ -976,22 +1017,22 @@ OptimizedPartitions.t => {
976
1017
 
977
1018
  let registerDynamicContracts = (
978
1019
  fetchState: t,
1020
+ ~indexingAddresses: IndexingAddresses.t,
979
1021
  // These are raw items which might have dynamic contracts received from contractRegister call.
980
1022
  // Might contain duplicates which we should filter out
981
1023
  items: array<Internal.item>,
982
1024
  ) => {
983
- if fetchState.normalSelection.eventConfigs->Utils.Array.isEmpty {
1025
+ if fetchState.normalSelection.onEventRegistrations->Utils.Array.isEmpty {
984
1026
  // Can the normalSelection be empty?
985
1027
  JsError.throwWithMessage(
986
1028
  "Invalid configuration. No events to fetch for the dynamic contract registration.",
987
1029
  )
988
1030
  }
989
1031
 
990
- let indexingAddresses = fetchState.indexingAddresses
991
1032
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
992
1033
  let earliestRegisteringEventBlockNumber = ref(%raw(`Infinity`))
993
1034
  // Addresses registered for contracts without matching events. These are not
994
- // added to partitions, but they are tracked on fetchState.indexingAddresses
1035
+ // added to partitions, but they are tracked on indexingAddresses
995
1036
  // so that later conflicting registrations are detected, and are persisted
996
1037
  // to envio_addresses so they can be picked up on restart with updated config.
997
1038
  let noEventsAddresses: dict<indexingAddress> = Dict.make()
@@ -1017,15 +1058,13 @@ let registerDynamicContracts = (
1017
1058
  address: dc.address,
1018
1059
  contractName: dc.contractName,
1019
1060
  registrationBlock: dc.registrationBlock,
1020
- effectiveStartBlock: deriveEffectiveStartBlock(
1061
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1021
1062
  ~registrationBlock=dc.registrationBlock,
1022
1063
  ~contractStartBlock,
1023
1064
  ),
1024
1065
  }
1025
1066
  // Prevent registering already indexing contracts
1026
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
1027
- dc.address->Address.toString,
1028
- ) {
1067
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1029
1068
  | Some(existingContract) =>
1030
1069
  // FIXME: Instead of filtering out duplicates,
1031
1070
  // we should check the block number first.
@@ -1080,7 +1119,7 @@ let registerDynamicContracts = (
1080
1119
  address: dc.address,
1081
1120
  contractName: dc.contractName,
1082
1121
  registrationBlock: dc.registrationBlock,
1083
- effectiveStartBlock: deriveEffectiveStartBlock(
1122
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1084
1123
  ~registrationBlock=dc.registrationBlock,
1085
1124
  ~contractStartBlock=None,
1086
1125
  ),
@@ -1088,9 +1127,7 @@ let registerDynamicContracts = (
1088
1127
  // Prevent duplicate logging/persistence when the same address is
1089
1128
  // already tracked on fetchState, either from the db on startup or
1090
1129
  // from an earlier registration in this batch.
1091
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
1092
- dc.address->Address.toString,
1093
- ) {
1130
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1094
1131
  | Some(existingContract) =>
1095
1132
  if existingContract.contractName != dc.contractName {
1096
1133
  fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
@@ -1144,12 +1181,10 @@ let registerDynamicContracts = (
1144
1181
  // Only dcs for contracts without events. Track them on
1145
1182
  // indexingAddresses so subsequent registrations see them, but don't touch
1146
1183
  // partitions since there's nothing to fetch for them.
1147
- let newIndexingContracts = indexingAddresses->Utils.Dict.shallowCopy
1148
- let _ = Utils.Dict.mergeInPlace(newIndexingContracts, noEventsAddresses)
1149
- fetchState->updateInternal(~indexingAddresses=newIndexingContracts)
1184
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1185
+ fetchState
1150
1186
  | (_, _) => {
1151
1187
  let newPartitions = []
1152
- let newIndexingAddresses = indexingAddresses->Utils.Dict.shallowCopy
1153
1188
  let dynamicContractsRef = ref(fetchState.optimizedPartitions.dynamicContracts)
1154
1189
  let mutExistingPartitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
1155
1190
 
@@ -1231,10 +1266,10 @@ let registerDynamicContracts = (
1231
1266
  }
1232
1267
 
1233
1268
  let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1234
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, registeringContracts)
1269
+ indexingAddresses->IndexingAddresses.register(registeringContracts)
1235
1270
  }
1236
1271
  // Include no-events dcs so later batches detect conflicts against them.
1237
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, noEventsAddresses)
1272
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1238
1273
 
1239
1274
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
1240
1275
  ~registeringContractsByContract,
@@ -1247,7 +1282,7 @@ let registerDynamicContracts = (
1247
1282
  ~progressBlockNumber=0,
1248
1283
  )
1249
1284
 
1250
- fetchState->updateInternal(~optimizedPartitions, ~indexingAddresses=newIndexingAddresses)
1285
+ fetchState->updateInternal(~optimizedPartitions)
1251
1286
  }
1252
1287
  }
1253
1288
  }
@@ -1260,6 +1295,7 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1260
1295
  */
1261
1296
  let handleQueryResult = (
1262
1297
  fetchState: t,
1298
+ ~indexingAddresses: IndexingAddresses.t,
1263
1299
  ~query: query,
1264
1300
  ~latestFetchedBlock: blockNumberAndTimestamp,
1265
1301
  ~newItems,
@@ -1270,9 +1306,10 @@ let handleQueryResult = (
1270
1306
  // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1271
1307
  let newItems = newItems->Array.filter(item =>
1272
1308
  switch item {
1273
- | Internal.Event({eventConfig, payload, blockNumber}) =>
1274
- switch eventConfig.clientAddressFilter {
1275
- | Some(filter) => filter(payload, blockNumber, fetchState.indexingAddresses)
1309
+ | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1310
+ switch onEventRegistration.clientAddressFilter {
1311
+ | Some(filter) =>
1312
+ filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1276
1313
  | None => true
1277
1314
  }
1278
1315
  | _ => true
@@ -1308,7 +1345,8 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1308
1345
  fromBlock: q.fromBlock,
1309
1346
  toBlock: q.toBlock,
1310
1347
  isChunk: q.isChunk,
1311
- estResponseSize: q.estResponseSize,
1348
+ itemsTarget: q.itemsTarget,
1349
+ itemsEst: q.itemsEst,
1312
1350
  fetchedBlock: None,
1313
1351
  }
1314
1352
 
@@ -1329,163 +1367,218 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1329
1367
  }
1330
1368
  }
1331
1369
 
1332
- @inline
1333
- let pushQueriesForRange = (
1370
+ // Most parallel in-flight chunk queries a single partition may have at once.
1371
+ let maxPendingChunksPerPartition = 12
1372
+
1373
+ // Fills a gap range (a hole left between completed/pending chunks, e.g. from an
1374
+ // out-of-order partial response) unconditionally — gaps are already-committed
1375
+ // range, not subject to this tick's water-fill budget. Priced by the
1376
+ // partition's trusted density when it has one; otherwise by the "available
1377
+ // density" — the partition's equal-divide budget spread over its remaining
1378
+ // range this tick — so a small gap reserves proportionally little instead of a
1379
+ // noisy one-sample estimate. Chunks only on a trusted POSITIVE density, same
1380
+ // rule as the water-fill. Returns the created queries' total itemsEst.
1381
+ let pushGapFillQueries = (
1334
1382
  queries: array<query>,
1335
1383
  ~partitionId: string,
1336
1384
  ~rangeFromBlock: int,
1337
1385
  ~rangeEndBlock: option<int>,
1338
- ~maxQueryBlockNumber: int,
1386
+ ~knownHeight: int,
1387
+ ~chainTargetBlock: int,
1339
1388
  ~maybeChunkRange: option<int>,
1389
+ ~maxChunks: int,
1340
1390
  ~partition: partition,
1391
+ ~partitionBudget: float,
1392
+ ~chunkItemsMultiplier: float,
1341
1393
  ~selection: selection,
1342
1394
  ~addressesByContractName: dict<array<Address.t>>,
1343
- ~indexingAddresses: dict<indexingAddress>,
1344
1395
  ) => {
1345
- if rangeFromBlock <= maxQueryBlockNumber {
1396
+ let cost = ref(0.)
1397
+
1398
+ // Gaps past the chain's target block wait: they regenerate from the
1399
+ // pending-walk each tick and fill once the target reaches them.
1400
+ if rangeFromBlock <= Pervasives.min(knownHeight, chainTargetBlock) && maxChunks > 0 {
1346
1401
  switch rangeEndBlock {
1347
1402
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1348
1403
  | _ =>
1349
- switch maybeChunkRange {
1350
- | None =>
1404
+ let trustedDensity = partition->getTrustedDensity
1405
+ let maxBlock = switch rangeEndBlock {
1406
+ | Some(eb) => eb
1407
+ | None => chainTargetBlock
1408
+ }
1409
+ let pushSingleQuery = (~density, ~isChunk) => {
1410
+ let itemsTarget = densityItemsTarget(
1411
+ ~density=density *. chunkItemsMultiplier,
1412
+ ~fromBlock=rangeFromBlock,
1413
+ ~toBlock=rangeEndBlock,
1414
+ ~chainTargetBlock,
1415
+ )
1416
+ let itemsEst = densityItemsTarget(
1417
+ ~density,
1418
+ ~fromBlock=rangeFromBlock,
1419
+ ~toBlock=rangeEndBlock,
1420
+ ~chainTargetBlock,
1421
+ )
1351
1422
  queries->Array.push({
1352
1423
  partitionId,
1353
1424
  fromBlock: rangeFromBlock,
1354
1425
  toBlock: rangeEndBlock,
1355
1426
  selection,
1356
- isChunk: false,
1357
- estResponseSize: calculateEstResponseSize(
1358
- partition,
1359
- ~fromBlock=rangeFromBlock,
1360
- ~toBlock=rangeEndBlock,
1361
- ~maxQueryBlockNumber,
1362
- ),
1363
- chainId: 0,
1364
- progress: 0.,
1427
+ isChunk,
1428
+ itemsTarget,
1429
+ itemsEst,
1365
1430
  addressesByContractName,
1366
- indexingAddresses,
1367
1431
  })
1368
- | Some(chunkRange) =>
1369
- let maxBlock = switch rangeEndBlock {
1370
- | Some(eb) => eb
1371
- | None => maxQueryBlockNumber
1372
- }
1432
+ cost := cost.contents +. itemsEst->Int.toFloat
1433
+ }
1434
+ switch (trustedDensity, maybeChunkRange) {
1435
+ | (Some(density), Some(chunkRange)) if density > 0. =>
1373
1436
  let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 1.8)
1374
- // Probe with two smaller chunks first so their responses come back
1375
- // quickly and refresh the chunking heuristic, then three full-size chunks.
1376
- let probeSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 0.9)
1377
- let getChunkSize = chunkIdx => chunkIdx < 2 ? probeSize : chunkSize
1378
- if rangeFromBlock + getChunkSize(0) + getChunkSize(1) - 1 <= maxBlock {
1437
+ if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
1379
1438
  let chunkFromBlock = ref(rangeFromBlock)
1380
1439
  let chunkIdx = ref(0)
1381
1440
  while (
1382
- chunkIdx.contents < 5 &&
1383
- chunkFromBlock.contents + getChunkSize(chunkIdx.contents) - 1 <= maxBlock
1441
+ chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1384
1442
  ) {
1385
- let chunkToBlock = chunkFromBlock.contents + getChunkSize(chunkIdx.contents) - 1
1443
+ let chunkToBlock = chunkFromBlock.contents + chunkSize - 1
1444
+ let itemsTarget = densityItemsTarget(
1445
+ ~density=density *. chunkItemsMultiplier,
1446
+ ~fromBlock=chunkFromBlock.contents,
1447
+ ~toBlock=Some(chunkToBlock),
1448
+ ~chainTargetBlock,
1449
+ )
1450
+ let itemsEst = densityItemsTarget(
1451
+ ~density,
1452
+ ~fromBlock=chunkFromBlock.contents,
1453
+ ~toBlock=Some(chunkToBlock),
1454
+ ~chainTargetBlock,
1455
+ )
1386
1456
  queries->Array.push({
1387
1457
  partitionId,
1388
1458
  fromBlock: chunkFromBlock.contents,
1389
1459
  toBlock: Some(chunkToBlock),
1390
1460
  isChunk: true,
1391
1461
  selection,
1392
- estResponseSize: calculateEstResponseSize(
1393
- partition,
1394
- ~fromBlock=chunkFromBlock.contents,
1395
- ~toBlock=Some(chunkToBlock),
1396
- ~maxQueryBlockNumber,
1397
- ),
1398
- chainId: 0,
1399
- progress: 0.,
1462
+ itemsTarget,
1463
+ itemsEst,
1400
1464
  addressesByContractName,
1401
- indexingAddresses,
1402
1465
  })
1466
+ cost := cost.contents +. itemsEst->Int.toFloat
1403
1467
  chunkFromBlock := chunkToBlock + 1
1404
1468
  chunkIdx := chunkIdx.contents + 1
1405
1469
  }
1406
1470
  } else {
1407
1471
  // Not enough room for 2 chunks, fall back to a single query
1408
- queries->Array.push({
1409
- partitionId,
1410
- fromBlock: rangeFromBlock,
1411
- toBlock: rangeEndBlock,
1412
- selection,
1413
- isChunk: rangeEndBlock !== None,
1414
- estResponseSize: calculateEstResponseSize(
1415
- partition,
1416
- ~fromBlock=rangeFromBlock,
1417
- ~toBlock=rangeEndBlock,
1418
- ~maxQueryBlockNumber,
1419
- ),
1420
- chainId: 0,
1421
- progress: 0.,
1422
- addressesByContractName,
1423
- indexingAddresses,
1424
- })
1472
+ pushSingleQuery(~density, ~isChunk=rangeEndBlock !== None)
1425
1473
  }
1474
+ | (Some(density), _) => pushSingleQuery(~density, ~isChunk=false)
1475
+ | (None, _) =>
1476
+ let remainingRange = Pervasives.max(1, chainTargetBlock - rangeFromBlock + 1)
1477
+ pushSingleQuery(~density=partitionBudget /. remainingRange->Int.toFloat, ~isChunk=false)
1426
1478
  }
1427
1479
  }
1428
1480
  }
1481
+ cost.contents
1429
1482
  }
1430
1483
 
1431
- // Most parallel in-flight chunk queries a single partition may have at once.
1432
- let maxPendingChunksPerPartition = 10
1484
+ // The level every partition ends at when `budget` is poured across partitions
1485
+ // already holding `footprints`: the unique L with Σ max(0, L - fᵢ) = budget.
1486
+ // Partitions above L get nothing (their head start is their share); the rest
1487
+ // are topped up exactly to L, so the pour equals the budget no matter how
1488
+ // uneven the footprints are.
1489
+ let waterLevel = (~budget: float, ~footprints: array<float>) => {
1490
+ let sorted = footprints->Array.toSorted(Float.compare)
1491
+ let n = sorted->Array.length
1492
+ let prefix = ref(0.)
1493
+ let level = ref(None)
1494
+ let idx = ref(0)
1495
+ while level.contents == None && idx.contents < n {
1496
+ let i = idx.contents
1497
+ prefix := prefix.contents +. sorted->Array.getUnsafe(i)
1498
+ // The level if only the i+1 lowest footprints receive water — correct once
1499
+ // it doesn't reach the next footprint up.
1500
+ let candidate = (budget +. prefix.contents) /. (i + 1)->Int.toFloat
1501
+ if i == n - 1 || candidate <= sorted->Array.getUnsafe(i + 1) {
1502
+ level := Some(candidate)
1503
+ }
1504
+ idx := idx.contents + 1
1505
+ }
1506
+ level.contents->Option.getOr(0.)
1507
+ }
1433
1508
 
1509
+ // Per-partition mutable state threaded through the water-fill rounds below.
1510
+ type waterFillState = {
1511
+ partitionId: string,
1512
+ p: partition,
1513
+ mutable cursor: int,
1514
+ mutable chunksUsedThisCall: int,
1515
+ // Existing mutPendingQueries count before this call — fixed for the call,
1516
+ // used with chunksUsedThisCall against maxPendingChunksPerPartition.
1517
+ pendingCount: int,
1518
+ queryEndBlock: option<int>,
1519
+ maybeChunkRange: option<int>,
1520
+ // This partition's own slot in queriesByPartitionIndex — gap-fill and every
1521
+ // water-fill round push here directly, so query order falls out of
1522
+ // idsInAscOrder for free (see getNextQuery) instead of a final sort pass.
1523
+ bucket: array<query>,
1524
+ }
1525
+
1526
+ // Candidate queries are sized against chainTargetBlock, the soft querying
1527
+ // horizon the owning chain wants to reach this tick — derived by ChainState
1528
+ // from its share of the indexer-wide buffer budget and its chain-level event
1529
+ // density. chainTargetBlock is never used as a hard query end, only to (a)
1530
+ // select which partitions are "in range" this tick and (b) size an open-ended
1531
+ // query with no other ceiling: the true hard bounds stay
1532
+ // endBlock/mergeBlock/the lagged head.
1533
+ //
1534
+ // In-range partitions share chainTargetItems (this chain's target total
1535
+ // footprint — in-flight plus new) by water-fill: each round pours exactly the
1536
+ // remaining fresh budget at the level computed by waterLevel, so a partition
1537
+ // already holding more than the level (from earlier ticks' in-flight queries)
1538
+ // gets nothing and its implicit share flows to the others, while totals stay
1539
+ // even and the pour never exceeds the budget. Rounds repeat only because
1540
+ // emits are quantized: a partition may consume less than its allotment (range
1541
+ // or chunk-cap runs out) or slightly more (min one chunk), and the leftover
1542
+ // re-pours over whoever still has range left.
1543
+ //
1544
+ // A partition with a trusted positive density (two or more responses — see
1545
+ // getMinHistoryRange) always emits at least one full-size chunk/query once
1546
+ // given an allotment, sized by its real density — may overshoot the
1547
+ // allotment by at most one chunk (the server also enforces itemsTarget via a
1548
+ // maxNumLogs-style cap, so an overshoot truncates the response rather than
1549
+ // the buffer). Any other partition (no signal, one noisy sample, or a
1550
+ // density-0 estimate) emits one open-ended probe sized exactly at its
1551
+ // allotment instead.
1434
1552
  let getNextQuery = (
1435
- {
1436
- buffer,
1437
- optimizedPartitions,
1438
- indexingAddresses,
1439
- blockLag,
1440
- latestOnBlockBlockNumber,
1441
- knownHeight,
1442
- } as fetchState: t,
1443
- ~budget,
1444
- ~chainPendingBudget,
1553
+ {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1554
+ ~chainTargetBlock: int,
1555
+ ~chainTargetItems: float,
1556
+ ~chunkItemsMultiplier: float=1.,
1445
1557
  ) => {
1446
1558
  let headBlockNumber = knownHeight - blockLag
1447
1559
  if headBlockNumber <= 0 {
1448
1560
  WaitingForNewBlock
1449
- } else if budget <= 0 {
1450
- // No room left in the shared buffer pool for this chain; wait for processing
1451
- // to drain before fetching more.
1452
- NothingToQuery
1453
1561
  } else {
1454
1562
  let isOnBlockBehindTheHead = latestOnBlockBlockNumber < headBlockNumber
1455
1563
  let shouldWaitForNewBlock = ref(
1456
- switch fetchState.endBlock {
1564
+ switch endBlock {
1457
1565
  | Some(endBlock) => headBlockNumber < endBlock
1458
1566
  | None => true
1459
1567
  } &&
1460
1568
  !isOnBlockBehindTheHead,
1461
1569
  )
1462
1570
 
1463
- // Limit how far ahead we fetch to budget items (plus what's already in
1464
- // flight) so processing always has buffer without ballooning memory. A
1465
- // partition that fetched further is skipped until the buffer drains.
1466
- let maxQueryBlockNumber = {
1467
- switch buffer->Array.get(budget + chainPendingBudget->Float.toInt - 1) {
1468
- | Some(item) =>
1469
- // Just in case check that we don't query beyond the current block
1470
- Pervasives.min(item->Internal.getItemBlockNumber, knownHeight)
1471
- | None => knownHeight
1472
- }
1473
- }
1474
-
1475
- let queries = []
1476
-
1477
1571
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1478
- let idxRef = ref(0)
1479
- while idxRef.contents < partitionsCount {
1480
- let idx = idxRef.contents
1572
+
1573
+ // Every partition is visited once here regardless of whether it gets a
1574
+ // query pushed below — waiting-for-new-block bookkeeping shouldn't depend
1575
+ // on this tick's budget.
1576
+ for idx in 0 to partitionsCount - 1 {
1481
1577
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1482
1578
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1483
-
1484
- let pendingCount = p.mutPendingQueries->Array.length
1485
- let isBehindTheHead = p.latestFetchedBlock.blockNumber < headBlockNumber
1486
- let hasPendingQueries = pendingCount > 0
1487
-
1488
- if hasPendingQueries || isBehindTheHead {
1579
+ if (
1580
+ p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1581
+ ) {
1489
1582
  // Even if there are some partitions waiting for the new block
1490
1583
  // We still want to wait for all partitions reaching the head
1491
1584
  // because they might update knownHeight in their response
@@ -1493,51 +1586,86 @@ let getNextQuery = (
1493
1586
  // and we don't want to poll the head for a few small partitions
1494
1587
  shouldWaitForNewBlock := false
1495
1588
  }
1589
+ }
1496
1590
 
1497
- let partitionQueriesStart = queries->Array.length
1498
-
1499
- // Compute queryEndBlock for this partition
1500
- let queryEndBlock = Utils.Math.minOptInt(fetchState.endBlock, p.mergeBlock)
1501
- let queryEndBlock = switch blockLag {
1591
+ // One bucket per partition, in idsInAscOrder order — gap-fill and
1592
+ // water-fill both push into a partition's own bucket, so flattening at
1593
+ // the end (see below) reproduces idsInAscOrder without a sort.
1594
+ let queriesByPartitionIndex: array<
1595
+ array<query>,
1596
+ > = Array.fromInitializer(~length=partitionsCount, _ => [])
1597
+
1598
+ // Compute queryEndBlock for this partition
1599
+ let computeQueryEndBlock = (p: partition) => {
1600
+ let queryEndBlock = Utils.Math.minOptInt(endBlock, p.mergeBlock)
1601
+ switch blockLag {
1502
1602
  | 0 => queryEndBlock
1503
1603
  | _ =>
1504
1604
  // Force head block as an endBlock when blockLag is set
1505
1605
  // because otherwise HyperSync might return bigger range
1506
1606
  Utils.Math.minOptInt(Some(headBlockNumber), queryEndBlock)
1507
1607
  }
1508
- // Enforce the response range up until target block
1509
- // Otherwise for indexers with 100+ partitions
1510
- // we might blow up the buffer size to more than 600k events
1511
- // simply because of HyperSync returning extra blocks
1512
- let queryEndBlock = switch (queryEndBlock, maxQueryBlockNumber < knownHeight) {
1513
- | (Some(endBlock), true) => Some(Pervasives.min(maxQueryBlockNumber, endBlock))
1514
- | (None, true) => Some(maxQueryBlockNumber)
1515
- | (_, false) => queryEndBlock
1608
+ }
1609
+
1610
+ // Each partition's existing in-flight itemsEst, summed once and reused
1611
+ // both for chainReserved (the call-wide fresh-budget ceiling below) and to
1612
+ // seed each partition's water-fill footprint so a partition already
1613
+ // holding in-flight queries is counted toward its even share and doesn't
1614
+ // get topped up past the others.
1615
+ let existingReservedByPartition = Dict.make()
1616
+ let chainReserved = ref(0.)
1617
+ for idx in 0 to partitionsCount - 1 {
1618
+ let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1619
+ let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1620
+ let cost = ref(0.)
1621
+ for pqIdx in 0 to p.mutPendingQueries->Array.length - 1 {
1622
+ cost := cost.contents +. (p.mutPendingQueries->Array.getUnsafe(pqIdx)).itemsEst->Int.toFloat
1516
1623
  }
1624
+ existingReservedByPartition->Dict.set(partitionId, cost.contents)
1625
+ chainReserved := chainReserved.contents +. cost.contents
1626
+ }
1517
1627
 
1628
+ // Phase A: gap-fill. Walk each partition's pending queries once,
1629
+ // unconditionally filling any hole (e.g. from an out-of-order partial
1630
+ // chunk response) — uncapped, same as before this tick's water-fill was
1631
+ // introduced. This also determines each partition's post-gap cursor and
1632
+ // whether it's blocked on an unresolved single-shot query.
1633
+ let fillStates = []
1634
+ let gapFillCost = ref(0.)
1635
+ for idx in 0 to partitionsCount - 1 {
1636
+ let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1637
+ let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1638
+ let bucket = queriesByPartitionIndex->Array.getUnsafe(idx)
1639
+ let pendingCount = p.mutPendingQueries->Array.length
1640
+ let queryEndBlock = computeQueryEndBlock(p)
1518
1641
  let maybeChunkRange = getMinHistoryRange(p)
1519
1642
 
1520
- // Walk pending queries to find open ranges and create queries for each
1521
1643
  let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1522
1644
  let canContinue = ref(true)
1645
+ let chunksUsedThisCall = ref(0)
1523
1646
  let pqIdx = ref(0)
1524
- while pqIdx.contents < p.mutPendingQueries->Array.length && canContinue.contents {
1647
+ while pqIdx.contents < pendingCount && canContinue.contents {
1525
1648
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1526
1649
 
1527
- // Gap before this pending query → create queries for the gap range
1528
1650
  if pq.fromBlock > cursor.contents {
1529
- pushQueriesForRange(
1530
- queries,
1651
+ let beforeLen = bucket->Array.length
1652
+ let cost = pushGapFillQueries(
1653
+ bucket,
1531
1654
  ~partitionId,
1532
1655
  ~rangeFromBlock=cursor.contents,
1533
1656
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1534
- ~maxQueryBlockNumber,
1657
+ ~knownHeight,
1658
+ ~chainTargetBlock,
1535
1659
  ~maybeChunkRange,
1660
+ ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1536
1661
  ~partition=p,
1662
+ ~partitionBudget=chainTargetItems /. partitionsCount->Int.toFloat,
1663
+ ~chunkItemsMultiplier,
1537
1664
  ~selection=p.selection,
1538
1665
  ~addressesByContractName=p.addressesByContractName,
1539
- ~indexingAddresses,
1540
1666
  )
1667
+ chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1668
+ gapFillCost := gapFillCost.contents +. cost
1541
1669
  }
1542
1670
  switch pq {
1543
1671
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1549,40 +1677,180 @@ let getNextQuery = (
1549
1677
  pqIdx := pqIdx.contents + 1
1550
1678
  }
1551
1679
 
1552
- // Tail range after all pending queries
1553
1680
  if canContinue.contents {
1554
- pushQueriesForRange(
1555
- queries,
1556
- ~partitionId,
1557
- ~rangeFromBlock=cursor.contents,
1558
- ~rangeEndBlock=queryEndBlock,
1559
- ~maxQueryBlockNumber,
1560
- ~maybeChunkRange,
1561
- ~partition=p,
1562
- ~selection=p.selection,
1563
- ~addressesByContractName=p.addressesByContractName,
1564
- ~indexingAddresses,
1565
- )
1681
+ fillStates
1682
+ ->Array.push({
1683
+ partitionId,
1684
+ p,
1685
+ cursor: cursor.contents,
1686
+ chunksUsedThisCall: chunksUsedThisCall.contents,
1687
+ pendingCount,
1688
+ queryEndBlock,
1689
+ maybeChunkRange,
1690
+ bucket,
1691
+ })
1692
+ ->ignore
1566
1693
  }
1694
+ }
1567
1695
 
1568
- // Cap parallel in-flight chunks per partition so a single partition can't
1569
- // monopolize fetching. Keep the earliest new chunks; the furthest-ahead
1570
- // ones wait for the next round once these resolve.
1571
- let maxNewChunks = Pervasives.max(0, maxPendingChunksPerPartition - pendingCount)
1572
- let generatedCount = queries->Array.length - partitionQueriesStart
1573
- if generatedCount > maxNewChunks {
1574
- queries
1575
- ->Array.splice(
1576
- ~start=partitionQueriesStart + maxNewChunks,
1577
- ~remove=generatedCount - maxNewChunks,
1578
- ~insert=[],
1579
- )
1580
- ->ignore
1696
+ // Fresh budget for this call what's left of chainTargetItems after
1697
+ // existing in-flight queries and this tick's gap-fill. The water-fill
1698
+ // rounds below hand it out; a partition already holding a large share
1699
+ // (seeded below) gets proportionally less so totals stay even.
1700
+ let rangeItemsTarget = Pervasives.max(
1701
+ 0.,
1702
+ chainTargetItems -. chainReserved.contents -. gapFillCost.contents,
1703
+ )
1704
+
1705
+ // Each in-range partition's running footprint: existing in-flight plus any
1706
+ // gap-fill just pushed into its bucket. The water-fill levels every
1707
+ // partition up toward a shared line, so this seed is what a partition
1708
+ // starts the fill already holding.
1709
+ let reservedByPartition = Dict.make()
1710
+ fillStates->Array.forEach(fs => {
1711
+ let cost = ref(existingReservedByPartition->Dict.getUnsafe(fs.partitionId))
1712
+ for i in 0 to fs.bucket->Array.length - 1 {
1713
+ cost := cost.contents +. (fs.bucket->Array.getUnsafe(i)).itemsEst->Int.toFloat
1581
1714
  }
1715
+ reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1716
+ })
1582
1717
 
1583
- idxRef := idxRef.contents + 1
1718
+ let isInRange = (fs: waterFillState) =>
1719
+ fs.cursor <= chainTargetBlock &&
1720
+ switch fs.queryEndBlock {
1721
+ | Some(eb) => fs.cursor <= eb
1722
+ | None => true
1723
+ } &&
1724
+ fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
1725
+
1726
+ let inRangeStates = fillStates->Array.filter(isInRange)
1727
+
1728
+ // Emits this round's queries for one partition, given its water-fill
1729
+ // allotment. Mutates fs.cursor/chunksUsedThisCall and returns the
1730
+ // itemsEst consumed.
1731
+ //
1732
+ // Chunks require a POSITIVE trusted density: density 0 prices every chunk
1733
+ // at ~nothing, letting a partition flood its full chunk pipeline with
1734
+ // hard-bounded queries that crawl 1.8× per two responses — an open-ended
1735
+ // probe instead gets the server's full scan range in one response.
1736
+ let emitQueries = (fs: waterFillState, ~budget: float) => {
1737
+ let p = fs.p
1738
+ let maxBlock = switch fs.queryEndBlock {
1739
+ | Some(eb) => eb
1740
+ | None => chainTargetBlock
1741
+ }
1742
+ switch (fs.maybeChunkRange, p->getTrustedDensity) {
1743
+ | (Some(minHistoryRange), Some(density)) if density > 0. =>
1744
+ // Chunking active: strict chunks with a hard endBlock, sized by real
1745
+ // density with the chunk headroom multiplier — at least one full
1746
+ // chunk this round even if the allotment falls short.
1747
+ let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1748
+ let maxChunksRemaining =
1749
+ maxPendingChunksPerPartition - fs.pendingCount - fs.chunksUsedThisCall
1750
+ let consumed = ref(0.)
1751
+ let created = ref(0)
1752
+ let chunkFromBlock = ref(fs.cursor)
1753
+ let budgetLeft = ref(true)
1754
+ // No chunk starts past chainTargetBlock; an emitted chunk still keeps
1755
+ // its full span (chunkToBlock may exceed the target — only
1756
+ // endBlock/mergeBlock are hard bounds).
1757
+ while (
1758
+ budgetLeft.contents &&
1759
+ created.contents < maxChunksRemaining &&
1760
+ chunkFromBlock.contents <= Pervasives.min(maxBlock, chainTargetBlock)
1761
+ ) {
1762
+ let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1763
+ let rawEst = density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat
1764
+ let itemsEst = Pervasives.max(1, rawEst->Math.ceil->Float.toInt)
1765
+ let itemsTarget = Pervasives.max(
1766
+ 1,
1767
+ (rawEst *. chunkItemsMultiplier)->Math.ceil->Float.toInt,
1768
+ )
1769
+ if consumed.contents == 0. || consumed.contents +. itemsEst->Int.toFloat <= budget {
1770
+ fs.bucket->Array.push({
1771
+ partitionId: fs.partitionId,
1772
+ fromBlock: chunkFromBlock.contents,
1773
+ toBlock: Some(chunkToBlock),
1774
+ isChunk: true,
1775
+ selection: p.selection,
1776
+ itemsTarget,
1777
+ itemsEst,
1778
+ addressesByContractName: p.addressesByContractName,
1779
+ })
1780
+ consumed := consumed.contents +. itemsEst->Int.toFloat
1781
+ chunkFromBlock := chunkToBlock + 1
1782
+ created := created.contents + 1
1783
+ } else {
1784
+ budgetLeft := false
1785
+ }
1786
+ }
1787
+ fs.cursor = chunkFromBlock.contents
1788
+ fs.chunksUsedThisCall = fs.chunksUsedThisCall + created.contents
1789
+ consumed.contents
1790
+ | _ =>
1791
+ let itemsTarget = Pervasives.max(1, Math.round(budget)->Float.toInt)
1792
+ let itemsEst = itemsTarget
1793
+ fs.bucket->Array.push({
1794
+ partitionId: fs.partitionId,
1795
+ fromBlock: fs.cursor,
1796
+ toBlock: fs.queryEndBlock,
1797
+ isChunk: false,
1798
+ selection: p.selection,
1799
+ itemsTarget,
1800
+ itemsEst,
1801
+ addressesByContractName: p.addressesByContractName,
1802
+ })
1803
+ fs.cursor = maxBlock + 1
1804
+ fs.chunksUsedThisCall = fs.chunksUsedThisCall + 1
1805
+ itemsEst->Int.toFloat
1806
+ }
1807
+ }
1808
+
1809
+ // Water-fill. Range membership is fixed by chainTargetBlock/queryEndBlock,
1810
+ // so the in-range partitions are filtered once up front; each round pours
1811
+ // the remaining fresh budget at the waterLevel of the still-not-filled
1812
+ // partitions' footprints and keeps only those still in range for the next
1813
+ // round. Allotments sum to exactly the poured budget, so the outcome is
1814
+ // order-independent and never exceeds it — the only overshoot left is the
1815
+ // min-one-chunk quantization in emitQueries, bounded by one chunk per
1816
+ // partition per round.
1817
+ //
1818
+ // No explicit round cap: every emit advances chunksUsedThisCall (capped at
1819
+ // maxPendingChunksPerPartition) and consumes a positive amount of fresh
1820
+ // budget, so the not-filled set drains on its own and the loop also stops
1821
+ // once the whole budget is poured.
1822
+ let notFilledPartitions = ref(inRangeStates)
1823
+ let remainingBudget = ref(rangeItemsTarget)
1824
+ while notFilledPartitions.contents->Array.length > 0 && remainingBudget.contents > 0. {
1825
+ let level = waterLevel(
1826
+ ~budget=remainingBudget.contents,
1827
+ ~footprints=notFilledPartitions.contents->Array.map(fs =>
1828
+ reservedByPartition->Dict.getUnsafe(fs.partitionId)
1829
+ ),
1830
+ )
1831
+ let next = []
1832
+ notFilledPartitions.contents->Array.forEach(fs => {
1833
+ let reserved = reservedByPartition->Dict.getUnsafe(fs.partitionId)
1834
+ let budget = level -. reserved
1835
+ if budget > 0. {
1836
+ let consumed = emitQueries(fs, ~budget)
1837
+ reservedByPartition->Dict.set(fs.partitionId, reserved +. consumed)
1838
+ remainingBudget := remainingBudget.contents -. consumed
1839
+
1840
+ if fs->isInRange {
1841
+ next->Array.push(fs)->ignore
1842
+ }
1843
+ }
1844
+ })
1845
+ notFilledPartitions := next
1584
1846
  }
1585
1847
 
1848
+ // Each partition pushed only into its own bucket (indexed by its
1849
+ // idsInAscOrder position), so flattening reproduces idsInAscOrder order
1850
+ // directly — no sort needed even though water-fill rounds interleave
1851
+ // across partitions.
1852
+ let queries = queriesByPartitionIndex->Array.flat
1853
+
1586
1854
  if queries->Utils.Array.isEmpty {
1587
1855
  if shouldWaitForNewBlock.contents {
1588
1856
  WaitingForNewBlock
@@ -1631,14 +1899,15 @@ Instantiates a fetch state with partitions for initial addresses
1631
1899
  let make = (
1632
1900
  ~startBlock,
1633
1901
  ~endBlock,
1634
- ~eventConfigs: array<Internal.eventConfig>,
1902
+ ~onEventRegistrations: array<Internal.onEventRegistration>,
1903
+ ~contractConfigs: dict<IndexingAddresses.contractConfig>,
1635
1904
  ~addresses: array<Internal.indexingAddress>,
1636
1905
  ~maxAddrInPartition,
1637
1906
  ~chainId,
1638
1907
  ~maxOnBlockBufferSize,
1639
1908
  ~knownHeight,
1640
1909
  ~progressBlockNumber=startBlock - 1,
1641
- ~onBlockConfigs=[],
1910
+ ~onBlockRegistrations=[],
1642
1911
  ~blockLag=0,
1643
1912
  ~firstEventBlock=None,
1644
1913
  ): t => {
@@ -1648,38 +1917,15 @@ let make = (
1648
1917
  }
1649
1918
 
1650
1919
  let notDependingOnAddresses = []
1651
- let normalEventConfigs = []
1920
+ let normalRegistrations = []
1652
1921
  let contractNamesWithNormalEvents = Utils.Set.make()
1653
- let indexingAddresses = Dict.make()
1654
- let contractConfigs: dict<contractConfig> = Dict.make()
1655
-
1656
- eventConfigs->Array.forEach(ec => {
1657
- switch contractConfigs->Utils.Dict.dangerouslyGetNonOption(ec.contractName) {
1658
- | Some({startBlock}) =>
1659
- contractConfigs->Dict.set(
1660
- ec.contractName,
1661
- {
1662
- startBlock: switch (startBlock, ec.startBlock) {
1663
- | (Some(a), Some(b)) => Some(Pervasives.min(a, b))
1664
- | (Some(_) as s, None) | (None, Some(_) as s) => s
1665
- | (None, None) => None
1666
- },
1667
- },
1668
- )
1669
- | None =>
1670
- contractConfigs->Dict.set(
1671
- ec.contractName,
1672
- {
1673
- startBlock: ec.startBlock,
1674
- },
1675
- )
1676
- }
1677
1922
 
1678
- if ec.dependsOnAddresses {
1679
- normalEventConfigs->Array.push(ec)
1680
- contractNamesWithNormalEvents->Utils.Set.add(ec.contractName)->ignore
1923
+ onEventRegistrations->Array.forEach(reg => {
1924
+ if reg.dependsOnAddresses {
1925
+ normalRegistrations->Array.push(reg)
1926
+ contractNamesWithNormalEvents->Utils.Set.add(reg.eventConfig.contractName)->ignore
1681
1927
  } else {
1682
- notDependingOnAddresses->Array.push(ec)
1928
+ notDependingOnAddresses->Array.push(reg)
1683
1929
  }
1684
1930
  })
1685
1931
 
@@ -1691,7 +1937,7 @@ let make = (
1691
1937
  latestFetchedBlock,
1692
1938
  selection: {
1693
1939
  dependsOnAddresses: false,
1694
- eventConfigs: notDependingOnAddresses,
1940
+ onEventRegistrations: notDependingOnAddresses,
1695
1941
  },
1696
1942
  addressesByContractName: Dict.make(),
1697
1943
  mergeBlock: None,
@@ -1706,7 +1952,7 @@ let make = (
1706
1952
 
1707
1953
  let normalSelection = {
1708
1954
  dependsOnAddresses: true,
1709
- eventConfigs: normalEventConfigs,
1955
+ onEventRegistrations: normalRegistrations,
1710
1956
  }
1711
1957
 
1712
1958
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
@@ -1714,32 +1960,16 @@ let make = (
1714
1960
 
1715
1961
  addresses->Array.forEach(contract => {
1716
1962
  let contractName = contract.contractName
1717
- let contractStartBlock = switch contractConfigs->Utils.Dict.dangerouslyGetNonOption(
1718
- contractName,
1719
- ) {
1720
- | Some({startBlock}) => startBlock
1721
- | None => None
1722
- }
1723
- let ia: indexingAddress = {
1724
- address: contract.address,
1725
- contractName: contract.contractName,
1726
- registrationBlock: contract.registrationBlock,
1727
- effectiveStartBlock: deriveEffectiveStartBlock(
1728
- ~registrationBlock=contract.registrationBlock,
1729
- ~contractStartBlock,
1730
- ),
1731
- }
1732
- // Track the address on fetchState regardless of whether it currently has
1733
- // matching events. This way, if the config is updated later to add events
1734
- // for this contract, the address is already known.
1735
- indexingAddresses->Dict.set(contract.address->Address.toString, ia)
1736
1963
 
1737
1964
  // Only addresses whose contract has events that depend on addresses get
1738
1965
  // registered for active fetching via partitions.
1739
1966
  if contractNamesWithNormalEvents->Utils.Set.has(contractName) {
1740
- let registeringContracts =
1741
- registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1742
- registeringContracts->Dict.set(contract.address->Address.toString, ia)
1967
+ registeringContractsByContract
1968
+ ->Utils.Dict.getOrInsertEmptyDict(contractName)
1969
+ ->Dict.set(
1970
+ contract.address->Address.toString,
1971
+ IndexingAddresses.makeIndexingAddress(~contract, ~contractConfigs),
1972
+ )
1743
1973
 
1744
1974
  // Detect dynamic contracts by registrationBlock
1745
1975
  if contract.registrationBlock !== -1 {
@@ -1758,12 +1988,15 @@ let make = (
1758
1988
  ~progressBlockNumber,
1759
1989
  )
1760
1990
 
1761
- if optimizedPartitions->OptimizedPartitions.count === 0 && onBlockConfigs->Utils.Array.isEmpty {
1991
+ if (
1992
+ optimizedPartitions->OptimizedPartitions.count === 0 &&
1993
+ onBlockRegistrations->Utils.Array.isEmpty
1994
+ ) {
1762
1995
  JsError.throwWithMessage(
1763
1996
  `Invalid configuration: Nothing to fetch on chain ${chainId->Int.toString}. ` ++
1764
1997
  `addresses=${addresses->Array.length->Int.toString}, ` ++
1765
- `eventConfigs=${eventConfigs->Array.length->Int.toString}, ` ++
1766
- `normalEventConfigs=${normalEventConfigs
1998
+ `onEventRegistrations=${onEventRegistrations->Array.length->Int.toString}, ` ++
1999
+ `normalRegistrations=${normalRegistrations
1767
2000
  ->Array.length
1768
2001
  ->Int.toString}. ` ++ `Make sure that you provided at least one contract address to index, or have events with Wildcard mode enabled, or have onBlock handlers.`,
1769
2002
  )
@@ -1774,14 +2007,14 @@ let make = (
1774
2007
  // fetching, so without seeding the buffer here getNextQuery would return
1775
2008
  // NothingToQuery and the indexer would get stuck.
1776
2009
  let buffer = []
1777
- let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockConfigs->Utils.Array.notEmpty {
2010
+ let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockRegistrations->Utils.Array.notEmpty {
1778
2011
  let maxBlockNumber = switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
1779
2012
  | None => knownHeight
1780
2013
  | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
1781
2014
  }
1782
2015
  appendOnBlockItems(
1783
2016
  ~mutItems=buffer,
1784
- ~onBlockConfigs,
2017
+ ~onBlockRegistrations,
1785
2018
  ~indexerStartBlock=startBlock,
1786
2019
  ~fromBlock=progressBlockNumber,
1787
2020
  ~maxBlockNumber,
@@ -1799,17 +2032,14 @@ let make = (
1799
2032
  endBlock,
1800
2033
  latestOnBlockBlockNumber,
1801
2034
  normalSelection,
1802
- indexingAddresses,
1803
2035
  blockLag,
1804
- onBlockConfigs,
2036
+ onBlockRegistrations,
1805
2037
  maxOnBlockBufferSize,
1806
2038
  knownHeight,
1807
2039
  buffer,
1808
2040
  firstEventBlock,
1809
2041
  }
1810
2042
 
1811
- let numAddresses = indexingAddresses->Utils.Dict.size
1812
- Prometheus.IndexingAddresses.set(~addressesCount=numAddresses, ~chainId)
1813
2043
  Prometheus.IndexingPartitions.set(
1814
2044
  ~partitionsCount=optimizedPartitions->OptimizedPartitions.count,
1815
2045
  ~chainId,
@@ -1826,25 +2056,6 @@ let make = (
1826
2056
 
1827
2057
  let bufferSize = ({buffer}: t) => buffer->Array.length
1828
2058
 
1829
- // Number of buffered items at or below the ready frontier (processable now,
1830
- // i.e. not stuck behind a gap from a lagging partition or out-of-order chunk).
1831
- // The buffer is kept sorted, so binary-search the frontier in O(log n).
1832
- let bufferReadyCount = (fetchState: t) => {
1833
- let frontier = fetchState->bufferBlockNumber
1834
- let buffer = fetchState.buffer
1835
- let lo = ref(0)
1836
- let hi = ref(buffer->Array.length)
1837
- while lo.contents < hi.contents {
1838
- let mid = (lo.contents + hi.contents) / 2
1839
- if buffer->Array.getUnsafe(mid)->Internal.getItemBlockNumber <= frontier {
1840
- lo := mid + 1
1841
- } else {
1842
- hi := mid
1843
- }
1844
- }
1845
- lo.contents
1846
- }
1847
-
1848
2059
  let rollbackPendingQueries = (mutPendingQueries: array<pendingQuery>, ~targetBlockNumber) => {
1849
2060
  // - Remove queries where fromBlock > target
1850
2061
  // - Cap fetchedBlock at target where fetchedBlock > target
@@ -1876,18 +2087,11 @@ Always recreates optimized partitions to avoid duplicate addresses:
1876
2087
  - Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
1877
2088
  - Non-wildcard with lfb > target: delete, track addresses for recreation
1878
2089
  */
1879
- let rollback = (fetchState: t, ~targetBlockNumber) => {
1880
- // Step 1: Build addressesToRemove and surviving indexingAddresses
1881
- let addressesToRemove = Utils.Set.make()
1882
- let indexingAddresses = Dict.make()
1883
-
1884
- fetchState.indexingAddresses->Utils.Dict.forEachWithKey((indexingContract, address) => {
1885
- if indexingContract.registrationBlock > targetBlockNumber {
1886
- let _ = addressesToRemove->Utils.Set.add(address->Address.unsafeFromString)
1887
- } else {
1888
- indexingAddresses->Dict.set(address, indexingContract)
1889
- }
1890
- })
2090
+ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetBlockNumber) => {
2091
+ // Step 1: Prune addresses registered after the target block. The pruned index is
2092
+ // then the source of truth for partition cleanup below — an address survives iff
2093
+ // it's still in the index.
2094
+ indexingAddresses->IndexingAddresses.rollbackInPlace(~targetBlockNumber)
1891
2095
 
1892
2096
  // Step 2: Categorize partitions
1893
2097
  let keptPartitions = []
@@ -1917,18 +2121,12 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1917
2121
  | _ if p.latestFetchedBlock.blockNumber > targetBlockNumber =>
1918
2122
  p.addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1919
2123
  addresses->Array.forEach(address => {
1920
- if (
1921
- !(addressesToRemove->Utils.Set.has(address)) &&
1922
- indexingAddresses
1923
- ->Utils.Dict.dangerouslyGetNonOption(address->Address.toString)
1924
- ->Option.isSome
1925
- ) {
2124
+ switch indexingAddresses->IndexingAddresses.get(address->Address.toString) {
2125
+ | Some(indexingContract) =>
1926
2126
  let registeringContracts =
1927
2127
  registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1928
- registeringContracts->Dict.set(
1929
- address->Address.toString,
1930
- indexingAddresses->Dict.getUnsafe(address->Address.toString),
1931
- )
2128
+ registeringContracts->Dict.set(address->Address.toString, indexingContract)
2129
+ | None => ()
1932
2130
  }
1933
2131
  })
1934
2132
  })
@@ -1941,11 +2139,13 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1941
2139
  | other => other
1942
2140
  }
1943
2141
 
1944
- // Remove addresses that should be removed
2142
+ // Drop addresses pruned from the index
1945
2143
  let rollbackedAddressesByContractName = Dict.make()
1946
2144
  addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1947
2145
  let keptAddresses =
1948
- addresses->Array.filter(address => !(addressesToRemove->Utils.Set.has(address)))
2146
+ addresses->Array.filter(address =>
2147
+ indexingAddresses->IndexingAddresses.get(address->Address.toString)->Option.isSome
2148
+ )
1949
2149
  if keptAddresses->Array.length > 0 {
1950
2150
  rollbackedAddressesByContractName->Dict.set(contractName, keptAddresses)
1951
2151
  }
@@ -1989,7 +2189,6 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1989
2189
  ),
1990
2190
  }->updateInternal(
1991
2191
  ~optimizedPartitions,
1992
- ~indexingAddresses,
1993
2192
  ~mutItems=fetchState.buffer->Array.filter(item =>
1994
2193
  switch item {
1995
2194
  | Event({blockNumber})
@@ -2084,17 +2283,6 @@ let getProgressPercentage = (fetchState: t) => {
2084
2283
  }
2085
2284
  }
2086
2285
 
2087
- // Progress a specific block sits at along the chain, used to order queries from
2088
- // different chains: a query starting further back (lower %) is fetched first.
2089
- let getProgressPercentageAt = (fetchState: t, ~blockNumber) => {
2090
- switch fetchState.firstEventBlock {
2091
- | None => 0.
2092
- | Some(firstEventBlock) =>
2093
- let totalRange = fetchState.knownHeight - firstEventBlock
2094
- totalRange <= 0 ? 0. : (blockNumber - firstEventBlock)->Int.toFloat /. totalRange->Int.toFloat
2095
- }
2096
- }
2097
-
2098
2286
  let sortForBatch = {
2099
2287
  let hasFullBatch = ({buffer} as fetchState: t, ~batchSizeTarget) => {
2100
2288
  switch buffer->Array.get(batchSizeTarget - 1) {