envio 3.3.0-alpha.0 → 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 (140) 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 +48 -16
  15. package/src/ChainFetching.res.mjs +36 -17
  16. package/src/ChainMetadata.res +1 -11
  17. package/src/ChainMetadata.res.mjs +1 -10
  18. package/src/ChainState.res +628 -149
  19. package/src/ChainState.res.mjs +440 -102
  20. package/src/ChainState.resi +74 -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 +142 -93
  27. package/src/CrossChainState.res.mjs +88 -60
  28. package/src/CrossChainState.resi +2 -10
  29. package/src/Ecosystem.res +5 -6
  30. package/src/Ecosystem.res.mjs +3 -3
  31. package/src/Env.res +0 -7
  32. package/src/Env.res.mjs +0 -6
  33. package/src/Envio.res +17 -11
  34. package/src/EnvioGlobal.res +53 -0
  35. package/src/EnvioGlobal.res.mjs +31 -0
  36. package/src/EventConfigBuilder.res +177 -88
  37. package/src/EventConfigBuilder.res.mjs +99 -42
  38. package/src/EventProcessing.res +74 -28
  39. package/src/EventProcessing.res.mjs +55 -29
  40. package/src/ExitOnCaughtUp.res +10 -2
  41. package/src/ExitOnCaughtUp.res.mjs +10 -1
  42. package/src/FetchState.res +558 -300
  43. package/src/FetchState.res.mjs +437 -423
  44. package/src/HandlerLoader.res +8 -104
  45. package/src/HandlerLoader.res.mjs +2 -81
  46. package/src/HandlerRegister.res +518 -144
  47. package/src/HandlerRegister.res.mjs +285 -133
  48. package/src/HandlerRegister.resi +24 -8
  49. package/src/IndexerLoop.res +2 -3
  50. package/src/IndexerLoop.res.mjs +1 -1
  51. package/src/IndexerState.res +10 -11
  52. package/src/IndexerState.res.mjs +14 -11
  53. package/src/IndexerState.resi +2 -5
  54. package/src/IndexingAddresses.res +108 -0
  55. package/src/IndexingAddresses.res.mjs +101 -0
  56. package/src/IndexingAddresses.resi +34 -0
  57. package/src/Internal.res +155 -46
  58. package/src/Internal.res.mjs +30 -2
  59. package/src/LoadLayer.res +5 -5
  60. package/src/LoadLayer.res.mjs +6 -6
  61. package/src/LogSelection.res +102 -165
  62. package/src/LogSelection.res.mjs +101 -116
  63. package/src/Main.res +71 -236
  64. package/src/Main.res.mjs +60 -145
  65. package/src/Metrics.res +74 -0
  66. package/src/Metrics.res.mjs +75 -0
  67. package/src/PgStorage.res +4 -4
  68. package/src/PgStorage.res.mjs +5 -5
  69. package/src/Prometheus.res +10 -74
  70. package/src/Prometheus.res.mjs +124 -186
  71. package/src/PruneStaleHistory.res +2 -2
  72. package/src/PruneStaleHistory.res.mjs +3 -3
  73. package/src/RawEvent.res +7 -2
  74. package/src/RawEvent.res.mjs +4 -4
  75. package/src/Rollback.res +3 -3
  76. package/src/Rollback.res.mjs +4 -4
  77. package/src/RollbackCommit.res +4 -1
  78. package/src/RollbackCommit.res.mjs +3 -2
  79. package/src/SimulateDeadInputTracker.res +85 -0
  80. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  81. package/src/SimulateDeadInputTracker.resi +12 -0
  82. package/src/SimulateItems.res +91 -23
  83. package/src/SimulateItems.res.mjs +55 -32
  84. package/src/TestIndexer.res +54 -40
  85. package/src/TestIndexer.res.mjs +37 -30
  86. package/src/bindings/Performance.res +7 -0
  87. package/src/bindings/Performance.res.mjs +21 -0
  88. package/src/bindings/Performance.resi +7 -0
  89. package/src/bindings/Viem.res +0 -41
  90. package/src/bindings/Viem.res.mjs +1 -43
  91. package/src/sources/BlockStore.res +46 -0
  92. package/src/sources/BlockStore.res.mjs +24 -0
  93. package/src/sources/EventRouter.res +36 -33
  94. package/src/sources/EventRouter.res.mjs +13 -14
  95. package/src/sources/Evm.res +83 -72
  96. package/src/sources/Evm.res.mjs +53 -69
  97. package/src/sources/EvmChain.res +18 -20
  98. package/src/sources/EvmChain.res.mjs +15 -15
  99. package/src/sources/EvmRpcClient.res +74 -5
  100. package/src/sources/EvmRpcClient.res.mjs +13 -4
  101. package/src/sources/FieldMask.res +39 -0
  102. package/src/sources/FieldMask.res.mjs +42 -0
  103. package/src/sources/Fuel.res +9 -6
  104. package/src/sources/Fuel.res.mjs +5 -10
  105. package/src/sources/HyperFuelSource.res +46 -50
  106. package/src/sources/HyperFuelSource.res.mjs +64 -54
  107. package/src/sources/HyperSync.res +39 -7
  108. package/src/sources/HyperSync.res.mjs +57 -37
  109. package/src/sources/HyperSync.resi +12 -2
  110. package/src/sources/HyperSyncClient.res +22 -111
  111. package/src/sources/HyperSyncClient.res.mjs +2 -30
  112. package/src/sources/HyperSyncHeightStream.res +1 -8
  113. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  114. package/src/sources/HyperSyncSource.res +94 -107
  115. package/src/sources/HyperSyncSource.res.mjs +82 -86
  116. package/src/sources/Rpc.res +15 -47
  117. package/src/sources/Rpc.res.mjs +25 -56
  118. package/src/sources/RpcSource.res +320 -467
  119. package/src/sources/RpcSource.res.mjs +268 -385
  120. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  121. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  122. package/src/sources/SimulateSource.res +10 -4
  123. package/src/sources/SimulateSource.res.mjs +16 -6
  124. package/src/sources/Source.res +34 -6
  125. package/src/sources/SourceManager.res +86 -27
  126. package/src/sources/SourceManager.res.mjs +84 -47
  127. package/src/sources/SourceManager.resi +12 -3
  128. package/src/sources/Svm.res +31 -16
  129. package/src/sources/Svm.res.mjs +36 -13
  130. package/src/sources/SvmHyperSyncClient.res +5 -32
  131. package/src/sources/SvmHyperSyncSource.res +159 -134
  132. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  133. package/src/sources/TransactionStore.res +49 -0
  134. package/src/sources/TransactionStore.res.mjs +30 -0
  135. package/src/tui/Tui.res +13 -16
  136. package/src/tui/Tui.res.mjs +12 -14
  137. package/svm.schema.json +49 -37
  138. package/src/bindings/Hrtime.res +0 -58
  139. package/src/bindings/Hrtime.res.mjs +0 -90
  140. 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,12 +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,
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,
22
25
  // Stores latestFetchedBlock when query completes. Only needed to persist
23
26
  // timestamp while earlier queries are still pending before updating
24
27
  // the partition's latestFetchedBlock.
@@ -47,6 +50,10 @@ type partition = {
47
50
  // Track last 3 successful query ranges for chunking heuristic (0 means no data)
48
51
  prevQueryRange: int,
49
52
  prevPrevQueryRange: int,
53
+ // Item count of the response that set prevQueryRange. Paired with it to derive
54
+ // the partition's event density (prevRangeSize / prevQueryRange) for sizing
55
+ // queries against the buffer budget.
56
+ prevRangeSize: int,
50
57
  // Tracks the latestFetchedBlock.blockNumber of the most recent response
51
58
  // that updated prevQueryRange. Prevents degradation of the chunking heuristic
52
59
  // when parallel query responses arrive out of order.
@@ -58,9 +65,50 @@ type query = {
58
65
  fromBlock: int,
59
66
  toBlock: option<int>,
60
67
  isChunk: bool,
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,
61
77
  selection: selection,
62
78
  addressesByContractName: dict<array<Address.t>>,
63
- indexingAddresses: dict<indexingAddress>,
79
+ }
80
+
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
+ )
64
112
  }
65
113
 
66
114
  // Calculate the chunk range from history using min-of-last-3-ranges heuristic
@@ -71,6 +119,15 @@ let getMinHistoryRange = (p: partition) => {
71
119
  }
72
120
  }
73
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
+
74
131
  let getMinQueryRange = (partitions: array<partition>) => {
75
132
  let min = ref(0)
76
133
  for i in 0 to partitions->Array.length - 1 {
@@ -150,6 +207,12 @@ module OptimizedPartitions = {
150
207
  let newId = nextPartitionIndexRef.contents->Int.toString
151
208
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
152
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.)
153
216
  {
154
217
  id: newId,
155
218
  dynamicContract: Some(contractName),
@@ -160,6 +223,7 @@ module OptimizedPartitions = {
160
223
  mutPendingQueries: [],
161
224
  prevQueryRange: minRange,
162
225
  prevPrevQueryRange: minRange,
226
+ prevRangeSize: (inheritedDensity *. minRange->Int.toFloat)->Math.ceil->Float.toInt,
163
227
  latestBlockRangeUpdateBlock: 0,
164
228
  }
165
229
  }
@@ -397,6 +461,7 @@ module OptimizedPartitions = {
397
461
  optimizedPartitions: t,
398
462
  ~query,
399
463
  ~knownHeight,
464
+ ~itemsCount,
400
465
  ~latestFetchedBlock: blockNumberAndTimestamp,
401
466
  ) => {
402
467
  let p = optimizedPartitions->getOrThrow(~partitionId=query.partitionId)
@@ -416,18 +481,25 @@ module OptimizedPartitions = {
416
481
  switch query.toBlock {
417
482
  | None => latestFetchedBlock.blockNumber < knownHeight - 10 // Don't update block range when very close to the head
418
483
  | Some(queryToBlock) =>
419
- // Update on partial response (direct capacity evidence),
420
- // or when the query's intended range covers at least the partition's
421
- // current chunk range meaning it was a capacity-based split chunk,
422
- // not a small gap-fill whose toBlock is an artificial boundary.
423
- 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.
424
494
  switch getMinHistoryRange(p) {
425
495
  | None => false // Chunking not active yet, don't update
426
496
  | Some(minHistoryRange) => queryToBlock - query.fromBlock + 1 >= minHistoryRange
427
497
  }
498
+ }
428
499
  }
429
500
  let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
430
501
  let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
502
+ let updatedPrevRangeSize = shouldUpdateBlockRange ? itemsCount : p.prevRangeSize
431
503
 
432
504
  // Process fetched queries from front of queue for main partition
433
505
  let updatedLatestFetchedBlock = consumeFetchedQueries(
@@ -449,6 +521,7 @@ module OptimizedPartitions = {
449
521
  latestFetchedBlock: updatedLatestFetchedBlock,
450
522
  prevQueryRange: updatedPrevQueryRange,
451
523
  prevPrevQueryRange: updatedPrevPrevQueryRange,
524
+ prevRangeSize: updatedPrevRangeSize,
452
525
  latestBlockRangeUpdateBlock: shouldUpdateBlockRange
453
526
  ? latestFetchedBlock.blockNumber
454
527
  : p.latestBlockRangeUpdateBlock,
@@ -480,10 +553,8 @@ type t = {
480
553
  startBlock: int,
481
554
  endBlock: option<int>,
482
555
  normalSelection: selection,
483
- // By address
484
- indexingAddresses: dict<indexingAddress>,
485
556
  // By contract name
486
- contractConfigs: dict<contractConfig>,
557
+ contractConfigs: dict<IndexingAddresses.contractConfig>,
487
558
  // Not used for logic - only metadata
488
559
  chainId: int,
489
560
  // The block number of the latest block which was added to the queue
@@ -497,10 +568,10 @@ type t = {
497
568
  // Buffer of items ordered from earliest to latest
498
569
  buffer: array<Internal.item>,
499
570
  // Caps how far ahead onBlock items are pre-generated (set to 2x the batch
500
- // size). Fetch depth is bounded separately by getNextQuery's bufferLimit, the
501
- // 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.
502
573
  maxOnBlockBufferSize: int,
503
- onBlockConfigs: array<Internal.onBlockConfig>,
574
+ onBlockRegistrations: array<Internal.onBlockRegistration>,
504
575
  knownHeight: int,
505
576
  firstEventBlock: option<int>,
506
577
  }
@@ -536,6 +607,25 @@ let bufferBlock = ({optimizedPartitions, latestOnBlockBlockNumber}: t) => {
536
607
  }
537
608
  }
538
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
+
539
629
  /*
540
630
  Comparitor for two events from the same chain. No need for chain id or timestamp
541
631
  */
@@ -551,15 +641,13 @@ let compareBufferItem = (a: Internal.item, b: Internal.item) => {
551
641
  // Some big number which should be bigger than any log index
552
642
  let blockItemLogIndex = 16777216
553
643
 
554
- let numAddresses = fetchState => fetchState.indexingAddresses->Utils.Dict.size
555
-
556
644
  // Appends Block items produced by the onBlock handlers for every block in
557
645
  // (fromBlock, maxBlockNumber] into mutItems and returns the new
558
646
  // latestOnBlockBlockNumber pointer. maxOnBlockBufferSize bounds how many items
559
647
  // are generated at once to prevent OOM.
560
648
  let appendOnBlockItems = (
561
649
  ~mutItems: array<Internal.item>,
562
- ~onBlockConfigs: array<Internal.onBlockConfig>,
650
+ ~onBlockRegistrations: array<Internal.onBlockRegistration>,
563
651
  ~indexerStartBlock,
564
652
  ~fromBlock,
565
653
  ~maxBlockNumber,
@@ -579,27 +667,27 @@ let appendOnBlockItems = (
579
667
  let blockNumber = latestOnBlockBlockNumber.contents + 1
580
668
  latestOnBlockBlockNumber := blockNumber
581
669
 
582
- for configIdx in 0 to onBlockConfigs->Array.length - 1 {
583
- let onBlockConfig = onBlockConfigs->Array.getUnsafe(configIdx)
670
+ for configIdx in 0 to onBlockRegistrations->Array.length - 1 {
671
+ let onBlockRegistration = onBlockRegistrations->Array.getUnsafe(configIdx)
584
672
 
585
- let handlerStartBlock = switch onBlockConfig.startBlock {
673
+ let handlerStartBlock = switch onBlockRegistration.startBlock {
586
674
  | Some(startBlock) => startBlock
587
675
  | None => indexerStartBlock
588
676
  }
589
677
 
590
678
  if (
591
679
  blockNumber >= handlerStartBlock &&
592
- switch onBlockConfig.endBlock {
680
+ switch onBlockRegistration.endBlock {
593
681
  | Some(endBlock) => blockNumber <= endBlock
594
682
  | None => true
595
683
  } &&
596
- (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockConfig.interval) === 0
684
+ (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockRegistration.interval) === 0
597
685
  ) {
598
686
  mutItems->Array.push(
599
687
  Block({
600
- onBlockConfig,
688
+ onBlockRegistration,
601
689
  blockNumber,
602
- logIndex: blockItemLogIndex + onBlockConfig.index,
690
+ logIndex: blockItemLogIndex + onBlockRegistration.index,
603
691
  }),
604
692
  )
605
693
  newItemsCounter := newItemsCounter.contents + 1
@@ -617,16 +705,15 @@ Runs partition optimization when partitions change.
617
705
  let updateInternal = (
618
706
  fetchState: t,
619
707
  ~optimizedPartitions=fetchState.optimizedPartitions,
620
- ~indexingAddresses=fetchState.indexingAddresses,
621
708
  ~mutItems=?,
622
709
  ~blockLag=fetchState.blockLag,
623
710
  ~knownHeight=fetchState.knownHeight,
624
711
  ): t => {
625
712
  let mutItemsRef = ref(mutItems)
626
713
 
627
- let latestOnBlockBlockNumber = switch fetchState.onBlockConfigs {
714
+ let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
628
715
  | [] => knownHeight
629
- | onBlockConfigs => {
716
+ | onBlockRegistrations => {
630
717
  // Calculate the max block number we are going to create items for
631
718
  // Use maxOnBlockBufferSize to get the last target item in the buffer
632
719
  //
@@ -654,7 +741,7 @@ let updateInternal = (
654
741
 
655
742
  appendOnBlockItems(
656
743
  ~mutItems,
657
- ~onBlockConfigs,
744
+ ~onBlockRegistrations,
658
745
  ~indexerStartBlock=fetchState.startBlock,
659
746
  ~fromBlock=fetchState.latestOnBlockBlockNumber,
660
747
  ~maxBlockNumber,
@@ -669,11 +756,10 @@ let updateInternal = (
669
756
  contractConfigs: fetchState.contractConfigs,
670
757
  normalSelection: fetchState.normalSelection,
671
758
  chainId: fetchState.chainId,
672
- onBlockConfigs: fetchState.onBlockConfigs,
759
+ onBlockRegistrations: fetchState.onBlockRegistrations,
673
760
  maxOnBlockBufferSize: fetchState.maxOnBlockBufferSize,
674
761
  optimizedPartitions,
675
762
  latestOnBlockBlockNumber,
676
- indexingAddresses,
677
763
  blockLag,
678
764
  knownHeight,
679
765
  buffer: switch mutItemsRef.contents {
@@ -701,12 +787,6 @@ let updateInternal = (
701
787
  ~blockNumber=updatedFetchState->bufferBlockNumber,
702
788
  ~chainId=fetchState.chainId,
703
789
  )
704
- if indexingAddresses !== fetchState.indexingAddresses {
705
- Prometheus.IndexingAddresses.set(
706
- ~addressesCount=updatedFetchState->numAddresses,
707
- ~chainId=fetchState.chainId,
708
- )
709
- }
710
790
 
711
791
  updatedFetchState
712
792
  }
@@ -838,6 +918,7 @@ OptimizedPartitions.t => {
838
918
  mutPendingQueries: [],
839
919
  prevQueryRange: 0,
840
920
  prevPrevQueryRange: 0,
921
+ prevRangeSize: 0,
841
922
  latestBlockRangeUpdateBlock: 0,
842
923
  })
843
924
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
@@ -936,22 +1017,22 @@ OptimizedPartitions.t => {
936
1017
 
937
1018
  let registerDynamicContracts = (
938
1019
  fetchState: t,
1020
+ ~indexingAddresses: IndexingAddresses.t,
939
1021
  // These are raw items which might have dynamic contracts received from contractRegister call.
940
1022
  // Might contain duplicates which we should filter out
941
1023
  items: array<Internal.item>,
942
1024
  ) => {
943
- if fetchState.normalSelection.eventConfigs->Utils.Array.isEmpty {
1025
+ if fetchState.normalSelection.onEventRegistrations->Utils.Array.isEmpty {
944
1026
  // Can the normalSelection be empty?
945
1027
  JsError.throwWithMessage(
946
1028
  "Invalid configuration. No events to fetch for the dynamic contract registration.",
947
1029
  )
948
1030
  }
949
1031
 
950
- let indexingAddresses = fetchState.indexingAddresses
951
1032
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
952
1033
  let earliestRegisteringEventBlockNumber = ref(%raw(`Infinity`))
953
1034
  // Addresses registered for contracts without matching events. These are not
954
- // added to partitions, but they are tracked on fetchState.indexingAddresses
1035
+ // added to partitions, but they are tracked on indexingAddresses
955
1036
  // so that later conflicting registrations are detected, and are persisted
956
1037
  // to envio_addresses so they can be picked up on restart with updated config.
957
1038
  let noEventsAddresses: dict<indexingAddress> = Dict.make()
@@ -977,15 +1058,13 @@ let registerDynamicContracts = (
977
1058
  address: dc.address,
978
1059
  contractName: dc.contractName,
979
1060
  registrationBlock: dc.registrationBlock,
980
- effectiveStartBlock: deriveEffectiveStartBlock(
1061
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
981
1062
  ~registrationBlock=dc.registrationBlock,
982
1063
  ~contractStartBlock,
983
1064
  ),
984
1065
  }
985
1066
  // Prevent registering already indexing contracts
986
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
987
- dc.address->Address.toString,
988
- ) {
1067
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
989
1068
  | Some(existingContract) =>
990
1069
  // FIXME: Instead of filtering out duplicates,
991
1070
  // we should check the block number first.
@@ -1040,7 +1119,7 @@ let registerDynamicContracts = (
1040
1119
  address: dc.address,
1041
1120
  contractName: dc.contractName,
1042
1121
  registrationBlock: dc.registrationBlock,
1043
- effectiveStartBlock: deriveEffectiveStartBlock(
1122
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1044
1123
  ~registrationBlock=dc.registrationBlock,
1045
1124
  ~contractStartBlock=None,
1046
1125
  ),
@@ -1048,9 +1127,7 @@ let registerDynamicContracts = (
1048
1127
  // Prevent duplicate logging/persistence when the same address is
1049
1128
  // already tracked on fetchState, either from the db on startup or
1050
1129
  // from an earlier registration in this batch.
1051
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
1052
- dc.address->Address.toString,
1053
- ) {
1130
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1054
1131
  | Some(existingContract) =>
1055
1132
  if existingContract.contractName != dc.contractName {
1056
1133
  fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
@@ -1104,12 +1181,10 @@ let registerDynamicContracts = (
1104
1181
  // Only dcs for contracts without events. Track them on
1105
1182
  // indexingAddresses so subsequent registrations see them, but don't touch
1106
1183
  // partitions since there's nothing to fetch for them.
1107
- let newIndexingContracts = indexingAddresses->Utils.Dict.shallowCopy
1108
- let _ = Utils.Dict.mergeInPlace(newIndexingContracts, noEventsAddresses)
1109
- fetchState->updateInternal(~indexingAddresses=newIndexingContracts)
1184
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1185
+ fetchState
1110
1186
  | (_, _) => {
1111
1187
  let newPartitions = []
1112
- let newIndexingAddresses = indexingAddresses->Utils.Dict.shallowCopy
1113
1188
  let dynamicContractsRef = ref(fetchState.optimizedPartitions.dynamicContracts)
1114
1189
  let mutExistingPartitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
1115
1190
 
@@ -1179,6 +1254,7 @@ let registerDynamicContracts = (
1179
1254
  mutPendingQueries: p.mutPendingQueries,
1180
1255
  prevQueryRange: p.prevQueryRange,
1181
1256
  prevPrevQueryRange: p.prevPrevQueryRange,
1257
+ prevRangeSize: p.prevRangeSize,
1182
1258
  latestBlockRangeUpdateBlock: p.latestBlockRangeUpdateBlock,
1183
1259
  })
1184
1260
  }
@@ -1190,10 +1266,10 @@ let registerDynamicContracts = (
1190
1266
  }
1191
1267
 
1192
1268
  let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1193
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, registeringContracts)
1269
+ indexingAddresses->IndexingAddresses.register(registeringContracts)
1194
1270
  }
1195
1271
  // Include no-events dcs so later batches detect conflicts against them.
1196
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, noEventsAddresses)
1272
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1197
1273
 
1198
1274
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
1199
1275
  ~registeringContractsByContract,
@@ -1206,7 +1282,7 @@ let registerDynamicContracts = (
1206
1282
  ~progressBlockNumber=0,
1207
1283
  )
1208
1284
 
1209
- fetchState->updateInternal(~optimizedPartitions, ~indexingAddresses=newIndexingAddresses)
1285
+ fetchState->updateInternal(~optimizedPartitions)
1210
1286
  }
1211
1287
  }
1212
1288
  }
@@ -1219,6 +1295,7 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1219
1295
  */
1220
1296
  let handleQueryResult = (
1221
1297
  fetchState: t,
1298
+ ~indexingAddresses: IndexingAddresses.t,
1222
1299
  ~query: query,
1223
1300
  ~latestFetchedBlock: blockNumberAndTimestamp,
1224
1301
  ~newItems,
@@ -1229,9 +1306,10 @@ let handleQueryResult = (
1229
1306
  // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1230
1307
  let newItems = newItems->Array.filter(item =>
1231
1308
  switch item {
1232
- | Internal.Event({eventConfig, payload, blockNumber}) =>
1233
- switch eventConfig.clientAddressFilter {
1234
- | 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)
1235
1313
  | None => true
1236
1314
  }
1237
1315
  | _ => true
@@ -1241,6 +1319,7 @@ let handleQueryResult = (
1241
1319
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1242
1320
  ~query,
1243
1321
  ~knownHeight=fetchState.knownHeight,
1322
+ ~itemsCount=newItems->Array.length,
1244
1323
  ~latestFetchedBlock,
1245
1324
  ),
1246
1325
  ~mutItems=?{
@@ -1253,7 +1332,6 @@ let handleQueryResult = (
1253
1332
  }
1254
1333
 
1255
1334
  type nextQuery =
1256
- | ReachedMaxConcurrency
1257
1335
  | WaitingForNewBlock
1258
1336
  | NothingToQuery
1259
1337
  | Ready(array<query>)
@@ -1267,6 +1345,8 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1267
1345
  fromBlock: q.fromBlock,
1268
1346
  toBlock: q.toBlock,
1269
1347
  isChunk: q.isChunk,
1348
+ itemsTarget: q.itemsTarget,
1349
+ itemsEst: q.itemsEst,
1270
1350
  fetchedBlock: None,
1271
1351
  }
1272
1352
 
@@ -1287,141 +1367,218 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1287
1367
  }
1288
1368
  }
1289
1369
 
1290
- @inline
1291
- 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 = (
1292
1382
  queries: array<query>,
1293
1383
  ~partitionId: string,
1294
1384
  ~rangeFromBlock: int,
1295
1385
  ~rangeEndBlock: option<int>,
1296
- ~maxQueryBlockNumber: int,
1386
+ ~knownHeight: int,
1387
+ ~chainTargetBlock: int,
1297
1388
  ~maybeChunkRange: option<int>,
1389
+ ~maxChunks: int,
1390
+ ~partition: partition,
1391
+ ~partitionBudget: float,
1392
+ ~chunkItemsMultiplier: float,
1298
1393
  ~selection: selection,
1299
1394
  ~addressesByContractName: dict<array<Address.t>>,
1300
- ~indexingAddresses: dict<indexingAddress>,
1301
1395
  ) => {
1302
- 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 {
1303
1401
  switch rangeEndBlock {
1304
1402
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1305
1403
  | _ =>
1306
- switch maybeChunkRange {
1307
- | 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
+ )
1308
1422
  queries->Array.push({
1309
1423
  partitionId,
1310
1424
  fromBlock: rangeFromBlock,
1311
1425
  toBlock: rangeEndBlock,
1312
1426
  selection,
1313
- isChunk: false,
1427
+ isChunk,
1428
+ itemsTarget,
1429
+ itemsEst,
1314
1430
  addressesByContractName,
1315
- indexingAddresses,
1316
1431
  })
1317
- | Some(chunkRange) =>
1318
- let maxBlock = switch rangeEndBlock {
1319
- | Some(eb) => eb
1320
- | None => maxQueryBlockNumber
1321
- }
1432
+ cost := cost.contents +. itemsEst->Int.toFloat
1433
+ }
1434
+ switch (trustedDensity, maybeChunkRange) {
1435
+ | (Some(density), Some(chunkRange)) if density > 0. =>
1322
1436
  let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 1.8)
1323
- // Probe with two smaller chunks first so their responses come back
1324
- // quickly and refresh the chunking heuristic, then three full-size chunks.
1325
- let probeSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 0.9)
1326
- let getChunkSize = chunkIdx => chunkIdx < 2 ? probeSize : chunkSize
1327
- if rangeFromBlock + getChunkSize(0) + getChunkSize(1) - 1 <= maxBlock {
1437
+ if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
1328
1438
  let chunkFromBlock = ref(rangeFromBlock)
1329
1439
  let chunkIdx = ref(0)
1330
1440
  while (
1331
- chunkIdx.contents < 5 &&
1332
- chunkFromBlock.contents + getChunkSize(chunkIdx.contents) - 1 <= maxBlock
1441
+ chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1333
1442
  ) {
1334
- 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
+ )
1335
1456
  queries->Array.push({
1336
1457
  partitionId,
1337
1458
  fromBlock: chunkFromBlock.contents,
1338
1459
  toBlock: Some(chunkToBlock),
1339
1460
  isChunk: true,
1340
1461
  selection,
1462
+ itemsTarget,
1463
+ itemsEst,
1341
1464
  addressesByContractName,
1342
- indexingAddresses,
1343
1465
  })
1466
+ cost := cost.contents +. itemsEst->Int.toFloat
1344
1467
  chunkFromBlock := chunkToBlock + 1
1345
1468
  chunkIdx := chunkIdx.contents + 1
1346
1469
  }
1347
1470
  } else {
1348
1471
  // Not enough room for 2 chunks, fall back to a single query
1349
- queries->Array.push({
1350
- partitionId,
1351
- fromBlock: rangeFromBlock,
1352
- toBlock: rangeEndBlock,
1353
- selection,
1354
- isChunk: rangeEndBlock !== None,
1355
- addressesByContractName,
1356
- indexingAddresses,
1357
- })
1472
+ pushSingleQuery(~density, ~isChunk=rangeEndBlock !== None)
1358
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)
1359
1478
  }
1360
1479
  }
1361
1480
  }
1481
+ cost.contents
1362
1482
  }
1363
1483
 
1364
- // Most parallel in-flight chunk queries a single partition may have at once.
1365
- 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
+ }
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
+ }
1366
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.
1367
1552
  let getNextQuery = (
1368
- {
1369
- buffer,
1370
- optimizedPartitions,
1371
- indexingAddresses,
1372
- blockLag,
1373
- latestOnBlockBlockNumber,
1374
- knownHeight,
1375
- } as fetchState: t,
1376
- ~concurrencyLimit,
1377
- ~bufferLimit,
1553
+ {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1554
+ ~chainTargetBlock: int,
1555
+ ~chainTargetItems: float,
1556
+ ~chunkItemsMultiplier: float=1.,
1378
1557
  ) => {
1379
1558
  let headBlockNumber = knownHeight - blockLag
1380
1559
  if headBlockNumber <= 0 {
1381
1560
  WaitingForNewBlock
1382
- } else if concurrencyLimit === 0 {
1383
- ReachedMaxConcurrency
1384
- } else if bufferLimit <= 0 {
1385
- // No room left in the shared buffer pool for this chain; wait for processing
1386
- // to drain before fetching more.
1387
- NothingToQuery
1388
1561
  } else {
1389
1562
  let isOnBlockBehindTheHead = latestOnBlockBlockNumber < headBlockNumber
1390
1563
  let shouldWaitForNewBlock = ref(
1391
- switch fetchState.endBlock {
1564
+ switch endBlock {
1392
1565
  | Some(endBlock) => headBlockNumber < endBlock
1393
1566
  | None => true
1394
1567
  } &&
1395
1568
  !isOnBlockBehindTheHead,
1396
1569
  )
1397
1570
 
1398
- // Limit how far ahead we fetch to bufferLimit items (this chain's share of
1399
- // the indexer-wide buffer pool) so processing always has buffer without
1400
- // ballooning memory. A partition that fetched further is skipped until the
1401
- // buffer drains.
1402
- let maxQueryBlockNumber = {
1403
- switch buffer->Array.get(bufferLimit - 1) {
1404
- | Some(item) =>
1405
- // Just in case check that we don't query beyond the current block
1406
- Pervasives.min(item->Internal.getItemBlockNumber, knownHeight)
1407
- | None => knownHeight
1408
- }
1409
- }
1410
-
1411
- let queries = []
1412
-
1413
1571
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1414
- let idxRef = ref(0)
1415
- while idxRef.contents < partitionsCount {
1416
- 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 {
1417
1577
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1418
1578
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1419
-
1420
- let pendingCount = p.mutPendingQueries->Array.length
1421
- let isBehindTheHead = p.latestFetchedBlock.blockNumber < headBlockNumber
1422
- let hasPendingQueries = pendingCount > 0
1423
-
1424
- if hasPendingQueries || isBehindTheHead {
1579
+ if (
1580
+ p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1581
+ ) {
1425
1582
  // Even if there are some partitions waiting for the new block
1426
1583
  // We still want to wait for all partitions reaching the head
1427
1584
  // because they might update knownHeight in their response
@@ -1429,50 +1586,86 @@ let getNextQuery = (
1429
1586
  // and we don't want to poll the head for a few small partitions
1430
1587
  shouldWaitForNewBlock := false
1431
1588
  }
1589
+ }
1432
1590
 
1433
- let partitionQueriesStart = queries->Array.length
1434
-
1435
- // Compute queryEndBlock for this partition
1436
- let queryEndBlock = Utils.Math.minOptInt(fetchState.endBlock, p.mergeBlock)
1437
- 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 {
1438
1602
  | 0 => queryEndBlock
1439
1603
  | _ =>
1440
1604
  // Force head block as an endBlock when blockLag is set
1441
1605
  // because otherwise HyperSync might return bigger range
1442
1606
  Utils.Math.minOptInt(Some(headBlockNumber), queryEndBlock)
1443
1607
  }
1444
- // Enforce the response range up until target block
1445
- // Otherwise for indexers with 100+ partitions
1446
- // we might blow up the buffer size to more than 600k events
1447
- // simply because of HyperSync returning extra blocks
1448
- let queryEndBlock = switch (queryEndBlock, maxQueryBlockNumber < knownHeight) {
1449
- | (Some(endBlock), true) => Some(Pervasives.min(maxQueryBlockNumber, endBlock))
1450
- | (None, true) => Some(maxQueryBlockNumber)
1451
- | (_, 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
1452
1623
  }
1624
+ existingReservedByPartition->Dict.set(partitionId, cost.contents)
1625
+ chainReserved := chainReserved.contents +. cost.contents
1626
+ }
1453
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)
1454
1641
  let maybeChunkRange = getMinHistoryRange(p)
1455
1642
 
1456
- // Walk pending queries to find open ranges and create queries for each
1457
1643
  let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1458
1644
  let canContinue = ref(true)
1645
+ let chunksUsedThisCall = ref(0)
1459
1646
  let pqIdx = ref(0)
1460
- while pqIdx.contents < p.mutPendingQueries->Array.length && canContinue.contents {
1647
+ while pqIdx.contents < pendingCount && canContinue.contents {
1461
1648
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1462
1649
 
1463
- // Gap before this pending query → create queries for the gap range
1464
1650
  if pq.fromBlock > cursor.contents {
1465
- pushQueriesForRange(
1466
- queries,
1651
+ let beforeLen = bucket->Array.length
1652
+ let cost = pushGapFillQueries(
1653
+ bucket,
1467
1654
  ~partitionId,
1468
1655
  ~rangeFromBlock=cursor.contents,
1469
1656
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1470
- ~maxQueryBlockNumber,
1657
+ ~knownHeight,
1658
+ ~chainTargetBlock,
1471
1659
  ~maybeChunkRange,
1660
+ ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1661
+ ~partition=p,
1662
+ ~partitionBudget=chainTargetItems /. partitionsCount->Int.toFloat,
1663
+ ~chunkItemsMultiplier,
1472
1664
  ~selection=p.selection,
1473
1665
  ~addressesByContractName=p.addressesByContractName,
1474
- ~indexingAddresses,
1475
1666
  )
1667
+ chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1668
+ gapFillCost := gapFillCost.contents +. cost
1476
1669
  }
1477
1670
  switch pq {
1478
1671
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1484,40 +1677,180 @@ let getNextQuery = (
1484
1677
  pqIdx := pqIdx.contents + 1
1485
1678
  }
1486
1679
 
1487
- // Tail range after all pending queries
1488
1680
  if canContinue.contents {
1489
- pushQueriesForRange(
1490
- queries,
1491
- ~partitionId,
1492
- ~rangeFromBlock=cursor.contents,
1493
- ~rangeEndBlock=queryEndBlock,
1494
- ~maxQueryBlockNumber,
1495
- ~maybeChunkRange,
1496
- ~selection=p.selection,
1497
- ~addressesByContractName=p.addressesByContractName,
1498
- ~indexingAddresses,
1499
- )
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
1500
1693
  }
1694
+ }
1501
1695
 
1502
- // Cap parallel in-flight chunks per partition so a single partition can't
1503
- // drain the whole concurrency budget (especially the high realtime one).
1504
- // Keep the earliest new chunks; the furthest-ahead ones wait for the next
1505
- // round once these resolve.
1506
- let maxNewChunks = Pervasives.max(0, maxPendingChunksPerPartition - pendingCount)
1507
- let generatedCount = queries->Array.length - partitionQueriesStart
1508
- if generatedCount > maxNewChunks {
1509
- queries
1510
- ->Array.splice(
1511
- ~start=partitionQueriesStart + maxNewChunks,
1512
- ~remove=generatedCount - maxNewChunks,
1513
- ~insert=[],
1514
- )
1515
- ->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
1516
1714
  }
1715
+ reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1716
+ })
1517
1717
 
1518
- 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
1519
1846
  }
1520
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
+
1521
1854
  if queries->Utils.Array.isEmpty {
1522
1855
  if shouldWaitForNewBlock.contents {
1523
1856
  WaitingForNewBlock
@@ -1525,13 +1858,6 @@ let getNextQuery = (
1525
1858
  NothingToQuery
1526
1859
  }
1527
1860
  } else {
1528
- // Enforce concurrency limit: sort by fromBlock and take the first concurrencyLimit
1529
- let queries = if queries->Array.length > concurrencyLimit {
1530
- queries->Array.sort((a, b) => Int.compare(a.fromBlock, b.fromBlock))
1531
- queries->Array.slice(~start=0, ~end=concurrencyLimit)
1532
- } else {
1533
- queries
1534
- }
1535
1861
  Ready(queries)
1536
1862
  }
1537
1863
  }
@@ -1573,14 +1899,15 @@ Instantiates a fetch state with partitions for initial addresses
1573
1899
  let make = (
1574
1900
  ~startBlock,
1575
1901
  ~endBlock,
1576
- ~eventConfigs: array<Internal.eventConfig>,
1902
+ ~onEventRegistrations: array<Internal.onEventRegistration>,
1903
+ ~contractConfigs: dict<IndexingAddresses.contractConfig>,
1577
1904
  ~addresses: array<Internal.indexingAddress>,
1578
1905
  ~maxAddrInPartition,
1579
1906
  ~chainId,
1580
1907
  ~maxOnBlockBufferSize,
1581
1908
  ~knownHeight,
1582
1909
  ~progressBlockNumber=startBlock - 1,
1583
- ~onBlockConfigs=[],
1910
+ ~onBlockRegistrations=[],
1584
1911
  ~blockLag=0,
1585
1912
  ~firstEventBlock=None,
1586
1913
  ): t => {
@@ -1590,38 +1917,15 @@ let make = (
1590
1917
  }
1591
1918
 
1592
1919
  let notDependingOnAddresses = []
1593
- let normalEventConfigs = []
1920
+ let normalRegistrations = []
1594
1921
  let contractNamesWithNormalEvents = Utils.Set.make()
1595
- let indexingAddresses = Dict.make()
1596
- let contractConfigs: dict<contractConfig> = Dict.make()
1597
-
1598
- eventConfigs->Array.forEach(ec => {
1599
- switch contractConfigs->Utils.Dict.dangerouslyGetNonOption(ec.contractName) {
1600
- | Some({startBlock}) =>
1601
- contractConfigs->Dict.set(
1602
- ec.contractName,
1603
- {
1604
- startBlock: switch (startBlock, ec.startBlock) {
1605
- | (Some(a), Some(b)) => Some(Pervasives.min(a, b))
1606
- | (Some(_) as s, None) | (None, Some(_) as s) => s
1607
- | (None, None) => None
1608
- },
1609
- },
1610
- )
1611
- | None =>
1612
- contractConfigs->Dict.set(
1613
- ec.contractName,
1614
- {
1615
- startBlock: ec.startBlock,
1616
- },
1617
- )
1618
- }
1619
1922
 
1620
- if ec.dependsOnAddresses {
1621
- normalEventConfigs->Array.push(ec)
1622
- 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
1623
1927
  } else {
1624
- notDependingOnAddresses->Array.push(ec)
1928
+ notDependingOnAddresses->Array.push(reg)
1625
1929
  }
1626
1930
  })
1627
1931
 
@@ -1633,7 +1937,7 @@ let make = (
1633
1937
  latestFetchedBlock,
1634
1938
  selection: {
1635
1939
  dependsOnAddresses: false,
1636
- eventConfigs: notDependingOnAddresses,
1940
+ onEventRegistrations: notDependingOnAddresses,
1637
1941
  },
1638
1942
  addressesByContractName: Dict.make(),
1639
1943
  mergeBlock: None,
@@ -1641,13 +1945,14 @@ let make = (
1641
1945
  mutPendingQueries: [],
1642
1946
  prevQueryRange: 0,
1643
1947
  prevPrevQueryRange: 0,
1948
+ prevRangeSize: 0,
1644
1949
  latestBlockRangeUpdateBlock: 0,
1645
1950
  })
1646
1951
  }
1647
1952
 
1648
1953
  let normalSelection = {
1649
1954
  dependsOnAddresses: true,
1650
- eventConfigs: normalEventConfigs,
1955
+ onEventRegistrations: normalRegistrations,
1651
1956
  }
1652
1957
 
1653
1958
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
@@ -1655,32 +1960,16 @@ let make = (
1655
1960
 
1656
1961
  addresses->Array.forEach(contract => {
1657
1962
  let contractName = contract.contractName
1658
- let contractStartBlock = switch contractConfigs->Utils.Dict.dangerouslyGetNonOption(
1659
- contractName,
1660
- ) {
1661
- | Some({startBlock}) => startBlock
1662
- | None => None
1663
- }
1664
- let ia: indexingAddress = {
1665
- address: contract.address,
1666
- contractName: contract.contractName,
1667
- registrationBlock: contract.registrationBlock,
1668
- effectiveStartBlock: deriveEffectiveStartBlock(
1669
- ~registrationBlock=contract.registrationBlock,
1670
- ~contractStartBlock,
1671
- ),
1672
- }
1673
- // Track the address on fetchState regardless of whether it currently has
1674
- // matching events. This way, if the config is updated later to add events
1675
- // for this contract, the address is already known.
1676
- indexingAddresses->Dict.set(contract.address->Address.toString, ia)
1677
1963
 
1678
1964
  // Only addresses whose contract has events that depend on addresses get
1679
1965
  // registered for active fetching via partitions.
1680
1966
  if contractNamesWithNormalEvents->Utils.Set.has(contractName) {
1681
- let registeringContracts =
1682
- registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1683
- 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
+ )
1684
1973
 
1685
1974
  // Detect dynamic contracts by registrationBlock
1686
1975
  if contract.registrationBlock !== -1 {
@@ -1699,12 +1988,15 @@ let make = (
1699
1988
  ~progressBlockNumber,
1700
1989
  )
1701
1990
 
1702
- if optimizedPartitions->OptimizedPartitions.count === 0 && onBlockConfigs->Utils.Array.isEmpty {
1991
+ if (
1992
+ optimizedPartitions->OptimizedPartitions.count === 0 &&
1993
+ onBlockRegistrations->Utils.Array.isEmpty
1994
+ ) {
1703
1995
  JsError.throwWithMessage(
1704
1996
  `Invalid configuration: Nothing to fetch on chain ${chainId->Int.toString}. ` ++
1705
1997
  `addresses=${addresses->Array.length->Int.toString}, ` ++
1706
- `eventConfigs=${eventConfigs->Array.length->Int.toString}, ` ++
1707
- `normalEventConfigs=${normalEventConfigs
1998
+ `onEventRegistrations=${onEventRegistrations->Array.length->Int.toString}, ` ++
1999
+ `normalRegistrations=${normalRegistrations
1708
2000
  ->Array.length
1709
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.`,
1710
2002
  )
@@ -1715,14 +2007,14 @@ let make = (
1715
2007
  // fetching, so without seeding the buffer here getNextQuery would return
1716
2008
  // NothingToQuery and the indexer would get stuck.
1717
2009
  let buffer = []
1718
- let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockConfigs->Utils.Array.notEmpty {
2010
+ let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockRegistrations->Utils.Array.notEmpty {
1719
2011
  let maxBlockNumber = switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
1720
2012
  | None => knownHeight
1721
2013
  | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
1722
2014
  }
1723
2015
  appendOnBlockItems(
1724
2016
  ~mutItems=buffer,
1725
- ~onBlockConfigs,
2017
+ ~onBlockRegistrations,
1726
2018
  ~indexerStartBlock=startBlock,
1727
2019
  ~fromBlock=progressBlockNumber,
1728
2020
  ~maxBlockNumber,
@@ -1740,17 +2032,14 @@ let make = (
1740
2032
  endBlock,
1741
2033
  latestOnBlockBlockNumber,
1742
2034
  normalSelection,
1743
- indexingAddresses,
1744
2035
  blockLag,
1745
- onBlockConfigs,
2036
+ onBlockRegistrations,
1746
2037
  maxOnBlockBufferSize,
1747
2038
  knownHeight,
1748
2039
  buffer,
1749
2040
  firstEventBlock,
1750
2041
  }
1751
2042
 
1752
- let numAddresses = indexingAddresses->Utils.Dict.size
1753
- Prometheus.IndexingAddresses.set(~addressesCount=numAddresses, ~chainId)
1754
2043
  Prometheus.IndexingPartitions.set(
1755
2044
  ~partitionsCount=optimizedPartitions->OptimizedPartitions.count,
1756
2045
  ~chainId,
@@ -1767,25 +2056,6 @@ let make = (
1767
2056
 
1768
2057
  let bufferSize = ({buffer}: t) => buffer->Array.length
1769
2058
 
1770
- // Number of buffered items at or below the ready frontier (processable now,
1771
- // i.e. not stuck behind a gap from a lagging partition or out-of-order chunk).
1772
- // The buffer is kept sorted, so binary-search the frontier in O(log n).
1773
- let bufferReadyCount = (fetchState: t) => {
1774
- let frontier = fetchState->bufferBlockNumber
1775
- let buffer = fetchState.buffer
1776
- let lo = ref(0)
1777
- let hi = ref(buffer->Array.length)
1778
- while lo.contents < hi.contents {
1779
- let mid = (lo.contents + hi.contents) / 2
1780
- if buffer->Array.getUnsafe(mid)->Internal.getItemBlockNumber <= frontier {
1781
- lo := mid + 1
1782
- } else {
1783
- hi := mid
1784
- }
1785
- }
1786
- lo.contents
1787
- }
1788
-
1789
2059
  let rollbackPendingQueries = (mutPendingQueries: array<pendingQuery>, ~targetBlockNumber) => {
1790
2060
  // - Remove queries where fromBlock > target
1791
2061
  // - Cap fetchedBlock at target where fetchedBlock > target
@@ -1817,18 +2087,11 @@ Always recreates optimized partitions to avoid duplicate addresses:
1817
2087
  - Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
1818
2088
  - Non-wildcard with lfb > target: delete, track addresses for recreation
1819
2089
  */
1820
- let rollback = (fetchState: t, ~targetBlockNumber) => {
1821
- // Step 1: Build addressesToRemove and surviving indexingAddresses
1822
- let addressesToRemove = Utils.Set.make()
1823
- let indexingAddresses = Dict.make()
1824
-
1825
- fetchState.indexingAddresses->Utils.Dict.forEachWithKey((indexingContract, address) => {
1826
- if indexingContract.registrationBlock > targetBlockNumber {
1827
- let _ = addressesToRemove->Utils.Set.add(address->Address.unsafeFromString)
1828
- } else {
1829
- indexingAddresses->Dict.set(address, indexingContract)
1830
- }
1831
- })
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)
1832
2095
 
1833
2096
  // Step 2: Categorize partitions
1834
2097
  let keptPartitions = []
@@ -1858,18 +2121,12 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1858
2121
  | _ if p.latestFetchedBlock.blockNumber > targetBlockNumber =>
1859
2122
  p.addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1860
2123
  addresses->Array.forEach(address => {
1861
- if (
1862
- !(addressesToRemove->Utils.Set.has(address)) &&
1863
- indexingAddresses
1864
- ->Utils.Dict.dangerouslyGetNonOption(address->Address.toString)
1865
- ->Option.isSome
1866
- ) {
2124
+ switch indexingAddresses->IndexingAddresses.get(address->Address.toString) {
2125
+ | Some(indexingContract) =>
1867
2126
  let registeringContracts =
1868
2127
  registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1869
- registeringContracts->Dict.set(
1870
- address->Address.toString,
1871
- indexingAddresses->Dict.getUnsafe(address->Address.toString),
1872
- )
2128
+ registeringContracts->Dict.set(address->Address.toString, indexingContract)
2129
+ | None => ()
1873
2130
  }
1874
2131
  })
1875
2132
  })
@@ -1882,11 +2139,13 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1882
2139
  | other => other
1883
2140
  }
1884
2141
 
1885
- // Remove addresses that should be removed
2142
+ // Drop addresses pruned from the index
1886
2143
  let rollbackedAddressesByContractName = Dict.make()
1887
2144
  addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1888
2145
  let keptAddresses =
1889
- addresses->Array.filter(address => !(addressesToRemove->Utils.Set.has(address)))
2146
+ addresses->Array.filter(address =>
2147
+ indexingAddresses->IndexingAddresses.get(address->Address.toString)->Option.isSome
2148
+ )
1890
2149
  if keptAddresses->Array.length > 0 {
1891
2150
  rollbackedAddressesByContractName->Dict.set(contractName, keptAddresses)
1892
2151
  }
@@ -1930,7 +2189,6 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1930
2189
  ),
1931
2190
  }->updateInternal(
1932
2191
  ~optimizedPartitions,
1933
- ~indexingAddresses,
1934
2192
  ~mutItems=fetchState.buffer->Array.filter(item =>
1935
2193
  switch item {
1936
2194
  | Event({blockNumber})