envio 3.3.0-alpha.1 → 3.3.0-alpha.11

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 (143) hide show
  1. package/evm.schema.json +10 -0
  2. package/index.d.ts +45 -25
  3. package/licenses/CLA.md +35 -0
  4. package/licenses/EULA.md +67 -0
  5. package/licenses/LICENSE.md +45 -0
  6. package/licenses/README.md +35 -0
  7. package/package.json +10 -9
  8. package/src/Address.res +5 -2
  9. package/src/Address.res.mjs +3 -1
  10. package/src/Api.res +1 -1
  11. package/src/Api.res.mjs +1 -1
  12. package/src/Batch.res +0 -6
  13. package/src/Batch.res.mjs +1 -12
  14. package/src/BatchProcessing.res +7 -12
  15. package/src/BatchProcessing.res.mjs +8 -7
  16. package/src/Bin.res +3 -0
  17. package/src/Bin.res.mjs +4 -0
  18. package/src/ChainFetching.res +53 -16
  19. package/src/ChainFetching.res.mjs +37 -19
  20. package/src/ChainMetadata.res +1 -11
  21. package/src/ChainMetadata.res.mjs +1 -10
  22. package/src/ChainState.res +630 -161
  23. package/src/ChainState.res.mjs +438 -101
  24. package/src/ChainState.resi +77 -4
  25. package/src/Config.res +70 -38
  26. package/src/Config.res.mjs +52 -12
  27. package/src/ContractRegisterContext.res +2 -12
  28. package/src/ContractRegisterContext.res.mjs +3 -5
  29. package/src/Core.res +34 -3
  30. package/src/Core.res.mjs +12 -0
  31. package/src/CrossChainState.res +130 -89
  32. package/src/CrossChainState.res.mjs +66 -61
  33. package/src/CrossChainState.resi +1 -1
  34. package/src/Ecosystem.res +5 -6
  35. package/src/Ecosystem.res.mjs +3 -3
  36. package/src/Envio.res +17 -11
  37. package/src/EnvioGlobal.res +53 -0
  38. package/src/EnvioGlobal.res.mjs +31 -0
  39. package/src/EventConfigBuilder.res +191 -127
  40. package/src/EventConfigBuilder.res.mjs +116 -66
  41. package/src/EventProcessing.res +77 -28
  42. package/src/EventProcessing.res.mjs +56 -29
  43. package/src/ExitOnCaughtUp.res +10 -2
  44. package/src/ExitOnCaughtUp.res.mjs +10 -1
  45. package/src/FetchState.res +684 -415
  46. package/src/FetchState.res.mjs +505 -486
  47. package/src/HandlerLoader.res +8 -104
  48. package/src/HandlerLoader.res.mjs +2 -81
  49. package/src/HandlerRegister.res +520 -144
  50. package/src/HandlerRegister.res.mjs +286 -133
  51. package/src/HandlerRegister.resi +24 -8
  52. package/src/IndexerState.res +10 -3
  53. package/src/IndexerState.res.mjs +11 -4
  54. package/src/IndexerState.resi +2 -1
  55. package/src/IndexingAddresses.res +108 -0
  56. package/src/IndexingAddresses.res.mjs +101 -0
  57. package/src/IndexingAddresses.resi +34 -0
  58. package/src/Internal.res +166 -48
  59. package/src/Internal.res.mjs +30 -2
  60. package/src/LoadLayer.res +5 -5
  61. package/src/LoadLayer.res.mjs +6 -6
  62. package/src/LogSelection.res +92 -217
  63. package/src/LogSelection.res.mjs +95 -184
  64. package/src/Main.res +71 -236
  65. package/src/Main.res.mjs +60 -145
  66. package/src/Metrics.res +74 -0
  67. package/src/Metrics.res.mjs +75 -0
  68. package/src/PgStorage.res +4 -4
  69. package/src/PgStorage.res.mjs +5 -5
  70. package/src/Prometheus.res +10 -63
  71. package/src/Prometheus.res.mjs +124 -171
  72. package/src/PruneStaleHistory.res +2 -2
  73. package/src/PruneStaleHistory.res.mjs +3 -3
  74. package/src/RawEvent.res +7 -2
  75. package/src/RawEvent.res.mjs +4 -4
  76. package/src/Rollback.res +3 -3
  77. package/src/Rollback.res.mjs +4 -4
  78. package/src/RollbackCommit.res +4 -1
  79. package/src/RollbackCommit.res.mjs +3 -2
  80. package/src/SimulateDeadInputTracker.res +85 -0
  81. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  82. package/src/SimulateDeadInputTracker.resi +12 -0
  83. package/src/SimulateItems.res +128 -28
  84. package/src/SimulateItems.res.mjs +82 -40
  85. package/src/TestIndexer.res +55 -41
  86. package/src/TestIndexer.res.mjs +39 -32
  87. package/src/TopicFilter.res +1 -25
  88. package/src/TopicFilter.res.mjs +0 -74
  89. package/src/bindings/Performance.res +7 -0
  90. package/src/bindings/Performance.res.mjs +21 -0
  91. package/src/bindings/Performance.resi +7 -0
  92. package/src/bindings/Viem.res +0 -46
  93. package/src/bindings/Viem.res.mjs +1 -43
  94. package/src/sources/BlockStore.res +46 -0
  95. package/src/sources/BlockStore.res.mjs +24 -0
  96. package/src/sources/EventRouter.res +31 -49
  97. package/src/sources/EventRouter.res.mjs +12 -25
  98. package/src/sources/Evm.res +83 -72
  99. package/src/sources/Evm.res.mjs +53 -69
  100. package/src/sources/EvmChain.res +7 -34
  101. package/src/sources/EvmChain.res.mjs +6 -27
  102. package/src/sources/EvmRpcClient.res +71 -5
  103. package/src/sources/EvmRpcClient.res.mjs +13 -4
  104. package/src/sources/FieldMask.res +39 -0
  105. package/src/sources/FieldMask.res.mjs +42 -0
  106. package/src/sources/Fuel.res +9 -6
  107. package/src/sources/Fuel.res.mjs +5 -10
  108. package/src/sources/HyperFuelSource.res +46 -50
  109. package/src/sources/HyperFuelSource.res.mjs +64 -54
  110. package/src/sources/HyperSync.res +45 -42
  111. package/src/sources/HyperSync.res.mjs +68 -60
  112. package/src/sources/HyperSync.resi +14 -4
  113. package/src/sources/HyperSyncClient.res +107 -119
  114. package/src/sources/HyperSyncClient.res.mjs +36 -31
  115. package/src/sources/HyperSyncHeightStream.res +1 -8
  116. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  117. package/src/sources/HyperSyncSource.res +60 -254
  118. package/src/sources/HyperSyncSource.res.mjs +57 -180
  119. package/src/sources/Rpc.res +15 -79
  120. package/src/sources/Rpc.res.mjs +25 -101
  121. package/src/sources/RpcSource.res +221 -514
  122. package/src/sources/RpcSource.res.mjs +216 -411
  123. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  124. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  125. package/src/sources/SimulateSource.res +44 -20
  126. package/src/sources/SimulateSource.res.mjs +41 -14
  127. package/src/sources/Source.res +34 -6
  128. package/src/sources/SourceManager.res +77 -19
  129. package/src/sources/SourceManager.res.mjs +63 -21
  130. package/src/sources/SourceManager.resi +10 -0
  131. package/src/sources/Svm.res +31 -16
  132. package/src/sources/Svm.res.mjs +36 -13
  133. package/src/sources/SvmHyperSyncClient.res +5 -32
  134. package/src/sources/SvmHyperSyncSource.res +159 -134
  135. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  136. package/src/sources/TransactionStore.res +49 -0
  137. package/src/sources/TransactionStore.res.mjs +30 -0
  138. package/src/tui/Tui.res +13 -16
  139. package/src/tui/Tui.res.mjs +12 -14
  140. package/svm.schema.json +49 -37
  141. package/src/bindings/Hrtime.res +0 -58
  142. package/src/bindings/Hrtime.res.mjs +0 -90
  143. 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,30 +607,124 @@ 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
  */
581
- let compareBufferItem = (a: Internal.item, b: Internal.item) => {
582
- let blockOrdering = Int.compare(a->Internal.getItemBlockNumber, b->Internal.getItemBlockNumber)
583
- if blockOrdering === Ordering.equal {
584
- Int.compare(a->Internal.getItemLogIndex, b->Internal.getItemLogIndex)
632
+ let getRegistrationIndex = (item: Internal.item): int =>
633
+ switch item {
634
+ | Event({onEventRegistration}) => onEventRegistration.index
635
+ | Block({onBlockRegistration}) => onBlockRegistration.index
636
+ }
637
+
638
+ // Total order on buffer items: block, then logIndex, then registration index.
639
+ // Returns a plain int (-1/0/1) with explicit field comparisons so it can be
640
+ // called directly from the merge/insertion loops below — no Array.sort callback,
641
+ // no allocated key. `0` means a true duplicate: same log routed to the same
642
+ // registration (two registrations for one log differ by index and are kept).
643
+ let compareBufferItem = (a: Internal.item, b: Internal.item): int => {
644
+ let ba = a->Internal.getItemBlockNumber
645
+ let bb = b->Internal.getItemBlockNumber
646
+ if ba != bb {
647
+ ba < bb ? -1 : 1
585
648
  } else {
586
- blockOrdering
649
+ let la = a->Internal.getItemLogIndex
650
+ let lb = b->Internal.getItemLogIndex
651
+ if la != lb {
652
+ la < lb ? -1 : 1
653
+ } else {
654
+ let ia = a->getRegistrationIndex
655
+ let ib = b->getRegistrationIndex
656
+ ia < ib ? -1 : ia > ib ? 1 : 0
657
+ }
587
658
  }
588
659
  }
589
660
 
661
+ // Merge a maybe-unsorted `newItems` run into the already-sorted, already-deduped
662
+ // `buffer`, dropping items equal on (blockNumber, logIndex, registration index).
663
+ // Single linear pass over both runs after ordering `newItems` in place; every
664
+ // comparison is a direct `compareBufferItem` call (V8 inlines it) rather than a
665
+ // callback through `Array.sort`.
666
+ let mergeIntoBuffer = (buffer: array<Internal.item>, newItems: array<Internal.item>): array<
667
+ Internal.item,
668
+ > => {
669
+ let n = newItems->Array.length
670
+ // Insertion sort: a source response is small and usually already ascending,
671
+ // so this is ~O(n) here.
672
+ for i in 1 to n - 1 {
673
+ let x = newItems->Array.getUnsafe(i)
674
+ let j = ref(i - 1)
675
+ while j.contents >= 0 && compareBufferItem(newItems->Array.getUnsafe(j.contents), x) > 0 {
676
+ newItems->Array.setUnsafe(j.contents + 1, newItems->Array.getUnsafe(j.contents))
677
+ j := j.contents - 1
678
+ }
679
+ newItems->Array.setUnsafe(j.contents + 1, x)
680
+ }
681
+
682
+ let m = buffer->Array.length
683
+ let merged = []
684
+ let last = ref(None)
685
+ let push = item =>
686
+ switch last.contents {
687
+ | Some(l) if compareBufferItem(l, item) === 0 => ()
688
+ | _ => {
689
+ merged->Array.push(item)
690
+ last := Some(item)
691
+ }
692
+ }
693
+
694
+ let i = ref(0)
695
+ let j = ref(0)
696
+ while i.contents < m && j.contents < n {
697
+ let a = buffer->Array.getUnsafe(i.contents)
698
+ let b = newItems->Array.getUnsafe(j.contents)
699
+ if compareBufferItem(a, b) <= 0 {
700
+ push(a)
701
+ i := i.contents + 1
702
+ } else {
703
+ push(b)
704
+ j := j.contents + 1
705
+ }
706
+ }
707
+ while i.contents < m {
708
+ push(buffer->Array.getUnsafe(i.contents))
709
+ i := i.contents + 1
710
+ }
711
+ while j.contents < n {
712
+ push(newItems->Array.getUnsafe(j.contents))
713
+ j := j.contents + 1
714
+ }
715
+ merged
716
+ }
717
+
590
718
  // Some big number which should be bigger than any log index
591
719
  let blockItemLogIndex = 16777216
592
720
 
593
- let numAddresses = fetchState => fetchState.indexingAddresses->Utils.Dict.size
594
-
595
721
  // Appends Block items produced by the onBlock handlers for every block in
596
722
  // (fromBlock, maxBlockNumber] into mutItems and returns the new
597
723
  // latestOnBlockBlockNumber pointer. maxOnBlockBufferSize bounds how many items
598
724
  // are generated at once to prevent OOM.
599
725
  let appendOnBlockItems = (
600
726
  ~mutItems: array<Internal.item>,
601
- ~onBlockConfigs: array<Internal.onBlockConfig>,
727
+ ~onBlockRegistrations: array<Internal.onBlockRegistration>,
602
728
  ~indexerStartBlock,
603
729
  ~fromBlock,
604
730
  ~maxBlockNumber,
@@ -618,27 +744,27 @@ let appendOnBlockItems = (
618
744
  let blockNumber = latestOnBlockBlockNumber.contents + 1
619
745
  latestOnBlockBlockNumber := blockNumber
620
746
 
621
- for configIdx in 0 to onBlockConfigs->Array.length - 1 {
622
- let onBlockConfig = onBlockConfigs->Array.getUnsafe(configIdx)
747
+ for configIdx in 0 to onBlockRegistrations->Array.length - 1 {
748
+ let onBlockRegistration = onBlockRegistrations->Array.getUnsafe(configIdx)
623
749
 
624
- let handlerStartBlock = switch onBlockConfig.startBlock {
750
+ let handlerStartBlock = switch onBlockRegistration.startBlock {
625
751
  | Some(startBlock) => startBlock
626
752
  | None => indexerStartBlock
627
753
  }
628
754
 
629
755
  if (
630
756
  blockNumber >= handlerStartBlock &&
631
- switch onBlockConfig.endBlock {
757
+ switch onBlockRegistration.endBlock {
632
758
  | Some(endBlock) => blockNumber <= endBlock
633
759
  | None => true
634
760
  } &&
635
- (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockConfig.interval) === 0
761
+ (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockRegistration.interval) === 0
636
762
  ) {
637
763
  mutItems->Array.push(
638
764
  Block({
639
- onBlockConfig,
765
+ onBlockRegistration,
640
766
  blockNumber,
641
- logIndex: blockItemLogIndex + onBlockConfig.index,
767
+ logIndex: blockItemLogIndex + onBlockRegistration.index,
642
768
  }),
643
769
  )
644
770
  newItemsCounter := newItemsCounter.contents + 1
@@ -656,50 +782,47 @@ Runs partition optimization when partitions change.
656
782
  let updateInternal = (
657
783
  fetchState: t,
658
784
  ~optimizedPartitions=fetchState.optimizedPartitions,
659
- ~indexingAddresses=fetchState.indexingAddresses,
660
785
  ~mutItems=?,
786
+ // Set when the caller already passes a sorted, deduped buffer (hot paths merge
787
+ // via mergeIntoBuffer or filter the sorted buffer). Otherwise mutItems is
788
+ // normalized here, so callers can hand over items in any order.
789
+ ~mutItemsSorted=false,
661
790
  ~blockLag=fetchState.blockLag,
662
791
  ~knownHeight=fetchState.knownHeight,
663
792
  ): t => {
664
- let mutItemsRef = ref(mutItems)
793
+ // The buffer to build on: the caller's items (normalized to sorted if needed),
794
+ // or the current buffer when only onBlock items change.
795
+ let base = switch mutItems {
796
+ | Some(items) => mutItemsSorted ? items : []->mergeIntoBuffer(items)
797
+ | None => fetchState.buffer
798
+ }
665
799
 
666
- let latestOnBlockBlockNumber = switch fetchState.onBlockConfigs {
800
+ // onBlock items are generated as their own ascending (block, logIndex) run and
801
+ // folded into `base` by the single merge below.
802
+ let blockItems = []
803
+ let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
667
804
  | [] => knownHeight
668
- | onBlockConfigs => {
669
- // Calculate the max block number we are going to create items for
670
- // Use maxOnBlockBufferSize to get the last target item in the buffer
671
- //
672
- // mutItems is not very reliable, since it might not be sorted,
673
- // but the chances for it happen are very low and not critical
674
- //
675
- // All this needed to prevent OOM when adding too many block items to the queue
676
- let maxBlockNumber = switch switch mutItemsRef.contents {
677
- | Some(mutItems) => mutItems
678
- | None => fetchState.buffer
679
- }->Array.get(fetchState.maxOnBlockBufferSize - 1) {
680
- | Some(item) => item->Internal.getItemBlockNumber
681
- | None =>
682
- switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
683
- | None => knownHeight
684
- | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
685
- }
686
- }
687
-
688
- let mutItems = switch mutItemsRef.contents {
689
- | Some(mutItems) => mutItems
690
- | None => fetchState.buffer->Array.copy
805
+ | onBlockRegistrations =>
806
+ // Calculate the max block number we are going to create items for
807
+ // Use maxOnBlockBufferSize to get the last target item in the buffer
808
+ // (sorted, so this is the highest-block item within the buffer cap).
809
+ // All this needed to prevent OOM when adding too many block items to the queue
810
+ let maxBlockNumber = switch base->Array.get(fetchState.maxOnBlockBufferSize - 1) {
811
+ | Some(item) => item->Internal.getItemBlockNumber
812
+ | None =>
813
+ switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
814
+ | None => knownHeight
815
+ | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
691
816
  }
692
- mutItemsRef := Some(mutItems)
693
-
694
- appendOnBlockItems(
695
- ~mutItems,
696
- ~onBlockConfigs,
697
- ~indexerStartBlock=fetchState.startBlock,
698
- ~fromBlock=fetchState.latestOnBlockBlockNumber,
699
- ~maxBlockNumber,
700
- ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
701
- )
702
817
  }
818
+ appendOnBlockItems(
819
+ ~mutItems=blockItems,
820
+ ~onBlockRegistrations,
821
+ ~indexerStartBlock=fetchState.startBlock,
822
+ ~fromBlock=fetchState.latestOnBlockBlockNumber,
823
+ ~maxBlockNumber,
824
+ ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
825
+ )
703
826
  }
704
827
 
705
828
  let updatedFetchState = {
@@ -708,22 +831,16 @@ let updateInternal = (
708
831
  contractConfigs: fetchState.contractConfigs,
709
832
  normalSelection: fetchState.normalSelection,
710
833
  chainId: fetchState.chainId,
711
- onBlockConfigs: fetchState.onBlockConfigs,
834
+ onBlockRegistrations: fetchState.onBlockRegistrations,
712
835
  maxOnBlockBufferSize: fetchState.maxOnBlockBufferSize,
713
836
  optimizedPartitions,
714
837
  latestOnBlockBlockNumber,
715
- indexingAddresses,
716
838
  blockLag,
717
839
  knownHeight,
718
- buffer: switch mutItemsRef.contents {
719
- // Theoretically it could be faster to asume that
720
- // the items are sorted, but there are cases
721
- // when the data source returns them unsorted
722
- | Some(mutItems) => {
723
- mutItems->Array.sort(compareBufferItem)
724
- mutItems
725
- }
726
- | None => fetchState.buffer
840
+ // Single merge point: fold any onBlock items into the sorted base buffer.
841
+ buffer: switch blockItems {
842
+ | [] => base
843
+ | blockItems => base->mergeIntoBuffer(blockItems)
727
844
  },
728
845
  firstEventBlock: fetchState.firstEventBlock,
729
846
  }
@@ -740,12 +857,6 @@ let updateInternal = (
740
857
  ~blockNumber=updatedFetchState->bufferBlockNumber,
741
858
  ~chainId=fetchState.chainId,
742
859
  )
743
- if indexingAddresses !== fetchState.indexingAddresses {
744
- Prometheus.IndexingAddresses.set(
745
- ~addressesCount=updatedFetchState->numAddresses,
746
- ~chainId=fetchState.chainId,
747
- )
748
- }
749
860
 
750
861
  updatedFetchState
751
862
  }
@@ -976,22 +1087,22 @@ OptimizedPartitions.t => {
976
1087
 
977
1088
  let registerDynamicContracts = (
978
1089
  fetchState: t,
1090
+ ~indexingAddresses: IndexingAddresses.t,
979
1091
  // These are raw items which might have dynamic contracts received from contractRegister call.
980
1092
  // Might contain duplicates which we should filter out
981
1093
  items: array<Internal.item>,
982
1094
  ) => {
983
- if fetchState.normalSelection.eventConfigs->Utils.Array.isEmpty {
1095
+ if fetchState.normalSelection.onEventRegistrations->Utils.Array.isEmpty {
984
1096
  // Can the normalSelection be empty?
985
1097
  JsError.throwWithMessage(
986
1098
  "Invalid configuration. No events to fetch for the dynamic contract registration.",
987
1099
  )
988
1100
  }
989
1101
 
990
- let indexingAddresses = fetchState.indexingAddresses
991
1102
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
992
1103
  let earliestRegisteringEventBlockNumber = ref(%raw(`Infinity`))
993
1104
  // Addresses registered for contracts without matching events. These are not
994
- // added to partitions, but they are tracked on fetchState.indexingAddresses
1105
+ // added to partitions, but they are tracked on indexingAddresses
995
1106
  // so that later conflicting registrations are detected, and are persisted
996
1107
  // to envio_addresses so they can be picked up on restart with updated config.
997
1108
  let noEventsAddresses: dict<indexingAddress> = Dict.make()
@@ -1017,15 +1128,13 @@ let registerDynamicContracts = (
1017
1128
  address: dc.address,
1018
1129
  contractName: dc.contractName,
1019
1130
  registrationBlock: dc.registrationBlock,
1020
- effectiveStartBlock: deriveEffectiveStartBlock(
1131
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1021
1132
  ~registrationBlock=dc.registrationBlock,
1022
1133
  ~contractStartBlock,
1023
1134
  ),
1024
1135
  }
1025
1136
  // Prevent registering already indexing contracts
1026
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
1027
- dc.address->Address.toString,
1028
- ) {
1137
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1029
1138
  | Some(existingContract) =>
1030
1139
  // FIXME: Instead of filtering out duplicates,
1031
1140
  // we should check the block number first.
@@ -1080,7 +1189,7 @@ let registerDynamicContracts = (
1080
1189
  address: dc.address,
1081
1190
  contractName: dc.contractName,
1082
1191
  registrationBlock: dc.registrationBlock,
1083
- effectiveStartBlock: deriveEffectiveStartBlock(
1192
+ effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1084
1193
  ~registrationBlock=dc.registrationBlock,
1085
1194
  ~contractStartBlock=None,
1086
1195
  ),
@@ -1088,9 +1197,7 @@ let registerDynamicContracts = (
1088
1197
  // Prevent duplicate logging/persistence when the same address is
1089
1198
  // already tracked on fetchState, either from the db on startup or
1090
1199
  // from an earlier registration in this batch.
1091
- switch indexingAddresses->Utils.Dict.dangerouslyGetNonOption(
1092
- dc.address->Address.toString,
1093
- ) {
1200
+ switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1094
1201
  | Some(existingContract) =>
1095
1202
  if existingContract.contractName != dc.contractName {
1096
1203
  fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
@@ -1144,12 +1251,10 @@ let registerDynamicContracts = (
1144
1251
  // Only dcs for contracts without events. Track them on
1145
1252
  // indexingAddresses so subsequent registrations see them, but don't touch
1146
1253
  // 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)
1254
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1255
+ fetchState
1150
1256
  | (_, _) => {
1151
1257
  let newPartitions = []
1152
- let newIndexingAddresses = indexingAddresses->Utils.Dict.shallowCopy
1153
1258
  let dynamicContractsRef = ref(fetchState.optimizedPartitions.dynamicContracts)
1154
1259
  let mutExistingPartitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
1155
1260
 
@@ -1231,10 +1336,10 @@ let registerDynamicContracts = (
1231
1336
  }
1232
1337
 
1233
1338
  let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1234
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, registeringContracts)
1339
+ indexingAddresses->IndexingAddresses.register(registeringContracts)
1235
1340
  }
1236
1341
  // Include no-events dcs so later batches detect conflicts against them.
1237
- let _ = Utils.Dict.mergeInPlace(newIndexingAddresses, noEventsAddresses)
1342
+ indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1238
1343
 
1239
1344
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
1240
1345
  ~registeringContractsByContract,
@@ -1247,11 +1352,31 @@ let registerDynamicContracts = (
1247
1352
  ~progressBlockNumber=0,
1248
1353
  )
1249
1354
 
1250
- fetchState->updateInternal(~optimizedPartitions, ~indexingAddresses=newIndexingAddresses)
1355
+ fetchState->updateInternal(~optimizedPartitions)
1251
1356
  }
1252
1357
  }
1253
1358
  }
1254
1359
 
1360
+ // Drop events an address-param filter rejects. A merged partition may
1361
+ // over-fetch a wildcard event whose indexed address param references an
1362
+ // address registered after the log's block; `clientAddressFilter` is the
1363
+ // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1364
+ let filterByClientAddress = (
1365
+ items: array<Internal.item>,
1366
+ ~indexingAddresses: IndexingAddresses.t,
1367
+ ): array<Internal.item> =>
1368
+ items->Array.filter(item =>
1369
+ switch item {
1370
+ | Internal.Event({payload, blockNumber}) as item =>
1371
+ switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1372
+ | Some(filter) =>
1373
+ filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1374
+ | None => true
1375
+ }
1376
+ | _ => true
1377
+ }
1378
+ )
1379
+
1255
1380
  /*
1256
1381
  Updates fetchState with a response for a given query.
1257
1382
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1264,20 +1389,6 @@ let handleQueryResult = (
1264
1389
  ~latestFetchedBlock: blockNumberAndTimestamp,
1265
1390
  ~newItems,
1266
1391
  ): t => {
1267
- // Drop events an address-param filter rejects. A merged partition may
1268
- // over-fetch a wildcard event whose indexed address param references an
1269
- // address registered after the log's block; `clientAddressFilter` is the
1270
- // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1271
- let newItems = newItems->Array.filter(item =>
1272
- switch item {
1273
- | Internal.Event({eventConfig, payload, blockNumber}) =>
1274
- switch eventConfig.clientAddressFilter {
1275
- | Some(filter) => filter(payload, blockNumber, fetchState.indexingAddresses)
1276
- | None => true
1277
- }
1278
- | _ => true
1279
- }
1280
- )
1281
1392
  fetchState->updateInternal(
1282
1393
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1283
1394
  ~query,
@@ -1285,10 +1396,14 @@ let handleQueryResult = (
1285
1396
  ~itemsCount=newItems->Array.length,
1286
1397
  ~latestFetchedBlock,
1287
1398
  ),
1399
+ // Merge the response into the sorted buffer, dropping duplicates an
1400
+ // overlapping query may re-deliver (e.g. an over-fetched log matched by two
1401
+ // partitions). Absorbs sorting too, so updateInternal doesn't re-sort.
1402
+ ~mutItemsSorted=true,
1288
1403
  ~mutItems=?{
1289
1404
  switch newItems {
1290
1405
  | [] => None
1291
- | _ => Some(fetchState.buffer->Array.concat(newItems))
1406
+ | _ => Some(fetchState.buffer->mergeIntoBuffer(newItems))
1292
1407
  }
1293
1408
  },
1294
1409
  )
@@ -1308,7 +1423,8 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1308
1423
  fromBlock: q.fromBlock,
1309
1424
  toBlock: q.toBlock,
1310
1425
  isChunk: q.isChunk,
1311
- estResponseSize: q.estResponseSize,
1426
+ itemsTarget: q.itemsTarget,
1427
+ itemsEst: q.itemsEst,
1312
1428
  fetchedBlock: None,
1313
1429
  }
1314
1430
 
@@ -1329,163 +1445,191 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1329
1445
  }
1330
1446
  }
1331
1447
 
1332
- @inline
1333
- let pushQueriesForRange = (
1448
+ // Most parallel in-flight chunk queries a single partition may have at once.
1449
+ let maxPendingChunksPerPartition = 12
1450
+
1451
+ // Generates candidate queries for a gap range (a hole left between
1452
+ // completed/pending chunks, e.g. from an out-of-order partial response). Gaps
1453
+ // carry the range's low fromBlock, so the acceptance pass takes them before
1454
+ // forward progress. Priced by the partition's trusted density when it has one;
1455
+ // otherwise by the "available density" — the partition's equal-divide budget
1456
+ // spread over its remaining range this tick — so a small gap reserves
1457
+ // proportionally little instead of a noisy one-sample estimate. Chunks only on
1458
+ // a trusted POSITIVE density. Returns the created queries' total itemsEst.
1459
+ let pushGapFillQueries = (
1334
1460
  queries: array<query>,
1335
1461
  ~partitionId: string,
1336
1462
  ~rangeFromBlock: int,
1337
1463
  ~rangeEndBlock: option<int>,
1338
- ~maxQueryBlockNumber: int,
1464
+ ~knownHeight: int,
1465
+ ~chainTargetBlock: int,
1339
1466
  ~maybeChunkRange: option<int>,
1467
+ ~maxChunks: int,
1340
1468
  ~partition: partition,
1469
+ ~partitionBudget: float,
1470
+ ~chunkItemsMultiplier: float,
1341
1471
  ~selection: selection,
1342
1472
  ~addressesByContractName: dict<array<Address.t>>,
1343
- ~indexingAddresses: dict<indexingAddress>,
1344
1473
  ) => {
1345
- if rangeFromBlock <= maxQueryBlockNumber {
1474
+ let cost = ref(0.)
1475
+
1476
+ // Gaps past the chain's target block wait: they regenerate from the
1477
+ // pending-walk each tick and fill once the target reaches them.
1478
+ if rangeFromBlock <= Pervasives.min(knownHeight, chainTargetBlock) && maxChunks > 0 {
1346
1479
  switch rangeEndBlock {
1347
1480
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1348
1481
  | _ =>
1349
- switch maybeChunkRange {
1350
- | None =>
1482
+ let trustedDensity = partition->getTrustedDensity
1483
+ let maxBlock = switch rangeEndBlock {
1484
+ | Some(eb) => eb
1485
+ | None => chainTargetBlock
1486
+ }
1487
+ let pushSingleQuery = (~density, ~isChunk) => {
1488
+ let itemsTarget = densityItemsTarget(
1489
+ ~density=density *. chunkItemsMultiplier,
1490
+ ~fromBlock=rangeFromBlock,
1491
+ ~toBlock=rangeEndBlock,
1492
+ ~chainTargetBlock,
1493
+ )
1494
+ let itemsEst = densityItemsTarget(
1495
+ ~density,
1496
+ ~fromBlock=rangeFromBlock,
1497
+ ~toBlock=rangeEndBlock,
1498
+ ~chainTargetBlock,
1499
+ )
1351
1500
  queries->Array.push({
1352
1501
  partitionId,
1353
1502
  fromBlock: rangeFromBlock,
1354
1503
  toBlock: rangeEndBlock,
1355
1504
  selection,
1356
- isChunk: false,
1357
- estResponseSize: calculateEstResponseSize(
1358
- partition,
1359
- ~fromBlock=rangeFromBlock,
1360
- ~toBlock=rangeEndBlock,
1361
- ~maxQueryBlockNumber,
1362
- ),
1363
- chainId: 0,
1364
- progress: 0.,
1505
+ isChunk,
1506
+ itemsTarget,
1507
+ itemsEst,
1365
1508
  addressesByContractName,
1366
- indexingAddresses,
1367
1509
  })
1368
- | Some(chunkRange) =>
1369
- let maxBlock = switch rangeEndBlock {
1370
- | Some(eb) => eb
1371
- | None => maxQueryBlockNumber
1372
- }
1510
+ cost := cost.contents +. itemsEst->Int.toFloat
1511
+ }
1512
+ switch (trustedDensity, maybeChunkRange) {
1513
+ | (Some(density), Some(chunkRange)) if density > 0. =>
1373
1514
  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 {
1515
+ if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
1379
1516
  let chunkFromBlock = ref(rangeFromBlock)
1380
1517
  let chunkIdx = ref(0)
1381
1518
  while (
1382
- chunkIdx.contents < 5 &&
1383
- chunkFromBlock.contents + getChunkSize(chunkIdx.contents) - 1 <= maxBlock
1519
+ chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1384
1520
  ) {
1385
- let chunkToBlock = chunkFromBlock.contents + getChunkSize(chunkIdx.contents) - 1
1521
+ let chunkToBlock = chunkFromBlock.contents + chunkSize - 1
1522
+ let itemsTarget = densityItemsTarget(
1523
+ ~density=density *. chunkItemsMultiplier,
1524
+ ~fromBlock=chunkFromBlock.contents,
1525
+ ~toBlock=Some(chunkToBlock),
1526
+ ~chainTargetBlock,
1527
+ )
1528
+ let itemsEst = densityItemsTarget(
1529
+ ~density,
1530
+ ~fromBlock=chunkFromBlock.contents,
1531
+ ~toBlock=Some(chunkToBlock),
1532
+ ~chainTargetBlock,
1533
+ )
1386
1534
  queries->Array.push({
1387
1535
  partitionId,
1388
1536
  fromBlock: chunkFromBlock.contents,
1389
1537
  toBlock: Some(chunkToBlock),
1390
1538
  isChunk: true,
1391
1539
  selection,
1392
- estResponseSize: calculateEstResponseSize(
1393
- partition,
1394
- ~fromBlock=chunkFromBlock.contents,
1395
- ~toBlock=Some(chunkToBlock),
1396
- ~maxQueryBlockNumber,
1397
- ),
1398
- chainId: 0,
1399
- progress: 0.,
1540
+ itemsTarget,
1541
+ itemsEst,
1400
1542
  addressesByContractName,
1401
- indexingAddresses,
1402
1543
  })
1544
+ cost := cost.contents +. itemsEst->Int.toFloat
1403
1545
  chunkFromBlock := chunkToBlock + 1
1404
1546
  chunkIdx := chunkIdx.contents + 1
1405
1547
  }
1406
1548
  } else {
1407
1549
  // 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
- })
1550
+ pushSingleQuery(~density, ~isChunk=rangeEndBlock !== None)
1425
1551
  }
1552
+ | (Some(density), _) => pushSingleQuery(~density, ~isChunk=false)
1553
+ | (None, _) =>
1554
+ let remainingRange = Pervasives.max(1, chainTargetBlock - rangeFromBlock + 1)
1555
+ pushSingleQuery(~density=partitionBudget /. remainingRange->Int.toFloat, ~isChunk=false)
1426
1556
  }
1427
1557
  }
1428
1558
  }
1559
+ cost.contents
1429
1560
  }
1430
1561
 
1431
- // Most parallel in-flight chunk queries a single partition may have at once.
1432
- let maxPendingChunksPerPartition = 10
1562
+ // Per-partition state carried from the gap-fill/cursor walk to candidate
1563
+ // generation.
1564
+ type partitionFillState = {
1565
+ partitionId: string,
1566
+ p: partition,
1567
+ cursor: int,
1568
+ // Chunks already generated for this partition during gap-fill — used with
1569
+ // pendingCount against maxPendingChunksPerPartition.
1570
+ chunksUsedThisCall: int,
1571
+ // Existing mutPendingQueries count before this call — fixed for the call.
1572
+ pendingCount: int,
1573
+ queryEndBlock: option<int>,
1574
+ maybeChunkRange: option<int>,
1575
+ }
1433
1576
 
1577
+ // Candidate queries are sized against chainTargetBlock, the soft querying
1578
+ // horizon the owning chain wants to reach this tick — derived by ChainState
1579
+ // from its share of the indexer-wide buffer budget and its chain-level event
1580
+ // density. chainTargetBlock is never used as a hard query end, only to (a)
1581
+ // select which partitions are "in range" this tick and (b) size an open-ended
1582
+ // query with no other ceiling: the true hard bounds stay
1583
+ // endBlock/mergeBlock/the lagged head.
1584
+ //
1585
+ // The tick's budget is chainTargetItems minus what's already in flight. Every
1586
+ // candidate query — gap-fill holes, plus each in-range partition's chunks or
1587
+ // open-ended probe toward the target — is generated with no budget check, then
1588
+ // the candidates are sorted by fromBlock and accepted in that order while the
1589
+ // budget stays positive. The query that tips it negative is still accepted (a
1590
+ // single overshoot); everything after it waits for a tick with more budget.
1591
+ // Sorting by fromBlock spends the budget on the earliest blocks across all
1592
+ // partitions first, so no partition is starved by generation order and the
1593
+ // frontier advances evenly. In-flight reservations release as responses land,
1594
+ // so acceptance redistributes across ticks.
1595
+ //
1596
+ // A partition with a trusted positive density (two or more responses — see
1597
+ // getMinHistoryRange) generates real, density-sized chunks toward the target.
1598
+ // Any other partition (no signal, one noisy sample, or a density-0 estimate)
1599
+ // generates one open-ended probe sized to the events its range to the target is
1600
+ // expected to hold — rangeTargetDensity × (chainTargetBlock − fromBlock + 1) /
1601
+ // inRangeCount — so several unknown-density partitions probe in parallel within
1602
+ // one budget, each scaled by how much of the range it still has to cover.
1434
1603
  let getNextQuery = (
1435
- {
1436
- buffer,
1437
- optimizedPartitions,
1438
- indexingAddresses,
1439
- blockLag,
1440
- latestOnBlockBlockNumber,
1441
- knownHeight,
1442
- } as fetchState: t,
1443
- ~budget,
1444
- ~chainPendingBudget,
1604
+ {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1605
+ ~chainTargetBlock: int,
1606
+ ~chainTargetItems: float,
1607
+ ~chunkItemsMultiplier: float=1.,
1445
1608
  ) => {
1446
1609
  let headBlockNumber = knownHeight - blockLag
1447
1610
  if headBlockNumber <= 0 {
1448
1611
  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
1612
  } else {
1454
1613
  let isOnBlockBehindTheHead = latestOnBlockBlockNumber < headBlockNumber
1455
1614
  let shouldWaitForNewBlock = ref(
1456
- switch fetchState.endBlock {
1615
+ switch endBlock {
1457
1616
  | Some(endBlock) => headBlockNumber < endBlock
1458
1617
  | None => true
1459
1618
  } &&
1460
1619
  !isOnBlockBehindTheHead,
1461
1620
  )
1462
1621
 
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
1622
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1478
- let idxRef = ref(0)
1479
- while idxRef.contents < partitionsCount {
1480
- let idx = idxRef.contents
1623
+
1624
+ // Every partition is visited once here regardless of whether it gets a
1625
+ // query pushed below — waiting-for-new-block bookkeeping shouldn't depend
1626
+ // on this tick's budget.
1627
+ for idx in 0 to partitionsCount - 1 {
1481
1628
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1482
1629
  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 {
1630
+ if (
1631
+ p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1632
+ ) {
1489
1633
  // Even if there are some partitions waiting for the new block
1490
1634
  // We still want to wait for all partitions reaching the head
1491
1635
  // because they might update knownHeight in their response
@@ -1493,51 +1637,99 @@ let getNextQuery = (
1493
1637
  // and we don't want to poll the head for a few small partitions
1494
1638
  shouldWaitForNewBlock := false
1495
1639
  }
1640
+ }
1496
1641
 
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 {
1642
+ // One bucket per partition, in idsInAscOrder order — gap-fill and the
1643
+ // budget pass both push into a partition's own bucket, so flattening at
1644
+ // the end (see below) reproduces idsInAscOrder without a sort.
1645
+ let queriesByPartitionIndex: array<
1646
+ array<query>,
1647
+ > = Array.fromInitializer(~length=partitionsCount, _ => [])
1648
+
1649
+ // Compute queryEndBlock for this partition
1650
+ let computeQueryEndBlock = (p: partition) => {
1651
+ let queryEndBlock = Utils.Math.minOptInt(endBlock, p.mergeBlock)
1652
+ switch blockLag {
1502
1653
  | 0 => queryEndBlock
1503
1654
  | _ =>
1504
1655
  // Force head block as an endBlock when blockLag is set
1505
1656
  // because otherwise HyperSync might return bigger range
1506
1657
  Utils.Math.minOptInt(Some(headBlockNumber), queryEndBlock)
1507
1658
  }
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
1516
- }
1659
+ }
1517
1660
 
1661
+ // In-flight itemsEst summed over queries still being fetched
1662
+ // (fetchedBlock === None). A query whose response already landed has had its
1663
+ // reservation released by ChainState even while it lingers in
1664
+ // mutPendingQueries behind an unfilled gap, so counting it here would
1665
+ // understate the budget. Sizes fresh forward work below.
1666
+ let chainReserved = ref(0.)
1667
+
1668
+ // (fromBlock, itemsEst) of each still-in-flight query. The acceptance pass
1669
+ // merges these into the candidate stream and draws them down in fromBlock
1670
+ // order, so a gap-fill sitting before an in-flight query claims budget ahead
1671
+ // of it and the buffer unblocks without waiting for that query to return.
1672
+ let reservations = []
1673
+
1674
+ // Position of each partition in idsInAscOrder, so an accepted query routes
1675
+ // back to its bucket and the output stays in idsInAscOrder. Filled in the
1676
+ // Phase A sweep below.
1677
+ let partitionIndexById = Dict.make()
1678
+
1679
+ // Every candidate query for this tick — gap-fill holes (Phase A) plus each
1680
+ // in-range partition's chunks/probe toward the target (Phase B) — generated
1681
+ // with no budget check, then merged with the in-flight reservations, sorted
1682
+ // by fromBlock, and accepted while the budget lasts (acceptance pass).
1683
+ // Selecting by fromBlock spends the budget on the earliest blocks across all
1684
+ // partitions first, so no partition is starved by iteration order and the
1685
+ // frontier advances evenly.
1686
+ let candidates = []
1687
+
1688
+ // Phase A: gap-fill. Walk each partition's pending queries once, generating
1689
+ // a candidate for any hole (e.g. from an out-of-order partial chunk
1690
+ // response). This also determines each partition's post-gap cursor and
1691
+ // whether it's blocked on an unresolved single-shot query.
1692
+ let fillStates = []
1693
+ for idx in 0 to partitionsCount - 1 {
1694
+ let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1695
+ let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1696
+ partitionIndexById->Dict.set(partitionId, idx)
1697
+ let pendingCount = p.mutPendingQueries->Array.length
1698
+ for pqIdx in 0 to pendingCount - 1 {
1699
+ let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx)
1700
+ if pq.fetchedBlock === None {
1701
+ chainReserved := chainReserved.contents +. pq.itemsEst->Int.toFloat
1702
+ reservations->Array.push((pq.fromBlock, pq.itemsEst))->ignore
1703
+ }
1704
+ }
1705
+ let queryEndBlock = computeQueryEndBlock(p)
1518
1706
  let maybeChunkRange = getMinHistoryRange(p)
1519
1707
 
1520
- // Walk pending queries to find open ranges and create queries for each
1521
1708
  let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1522
1709
  let canContinue = ref(true)
1710
+ let chunksUsedThisCall = ref(0)
1523
1711
  let pqIdx = ref(0)
1524
- while pqIdx.contents < p.mutPendingQueries->Array.length && canContinue.contents {
1712
+ while pqIdx.contents < pendingCount && canContinue.contents {
1525
1713
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1526
1714
 
1527
- // Gap before this pending query → create queries for the gap range
1528
1715
  if pq.fromBlock > cursor.contents {
1529
- pushQueriesForRange(
1530
- queries,
1716
+ let beforeLen = candidates->Array.length
1717
+ pushGapFillQueries(
1718
+ candidates,
1531
1719
  ~partitionId,
1532
1720
  ~rangeFromBlock=cursor.contents,
1533
1721
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1534
- ~maxQueryBlockNumber,
1722
+ ~knownHeight,
1723
+ ~chainTargetBlock,
1535
1724
  ~maybeChunkRange,
1725
+ ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1536
1726
  ~partition=p,
1727
+ ~partitionBudget=chainTargetItems /. partitionsCount->Int.toFloat,
1728
+ ~chunkItemsMultiplier,
1537
1729
  ~selection=p.selection,
1538
1730
  ~addressesByContractName=p.addressesByContractName,
1539
- ~indexingAddresses,
1540
- )
1731
+ )->ignore
1732
+ chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
1541
1733
  }
1542
1734
  switch pq {
1543
1735
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1549,40 +1741,195 @@ let getNextQuery = (
1549
1741
  pqIdx := pqIdx.contents + 1
1550
1742
  }
1551
1743
 
1552
- // Tail range after all pending queries
1553
1744
  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
- )
1745
+ fillStates
1746
+ ->Array.push({
1747
+ partitionId,
1748
+ p,
1749
+ cursor: cursor.contents,
1750
+ chunksUsedThisCall: chunksUsedThisCall.contents,
1751
+ pendingCount,
1752
+ queryEndBlock,
1753
+ maybeChunkRange,
1754
+ })
1755
+ ->ignore
1566
1756
  }
1757
+ }
1567
1758
 
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
- )
1759
+ // Budget for fresh forward work: chainTargetItems minus what's still in
1760
+ // flight. Sizes probes and bounds chunk generation below; the acceptance
1761
+ // pass does the final budgeting against the full chainTargetItems.
1762
+ let freshBudget = Pervasives.max(0., chainTargetItems -. chainReserved.contents)
1763
+
1764
+ let isInRange = (fs: partitionFillState) =>
1765
+ fs.cursor <= chainTargetBlock &&
1766
+ switch fs.queryEndBlock {
1767
+ | Some(eb) => fs.cursor <= eb
1768
+ | None => true
1769
+ } &&
1770
+ fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
1771
+
1772
+ let inRangeStates = fillStates->Array.filter(isInRange)
1773
+ let inRangeCount = inRangeStates->Array.length
1774
+ // Even share of the fresh budget across the partitions actually fetching
1775
+ // this tick (not every partition — so budget isn't stranded on ones below
1776
+ // the head, waiting, or already done). The fallback when there's no range to
1777
+ // the target.
1778
+ let probeShare = inRangeCount == 0 ? 0. : freshBudget /. inRangeCount->Int.toFloat
1779
+ // Items/block the budget implies over the range those partitions cover this
1780
+ // tick — from the furthest-behind in-range cursor to the target. A probe
1781
+ // covering less of that range (its partition sits further ahead) gets
1782
+ // proportionally fewer items; one starting at the frontier gets the full
1783
+ // even share.
1784
+ let frontierCursor =
1785
+ inRangeStates->Array.reduce(chainTargetBlock, (min, fs) => fs.cursor < min ? fs.cursor : min)
1786
+ let rangeToTarget = chainTargetBlock - frontierCursor + 1
1787
+ let rangeTargetDensity =
1788
+ inRangeCount > 0 && rangeToTarget > 0 ? freshBudget /. rangeToTarget->Int.toFloat : 0.
1789
+
1790
+ // Phase B: generate each in-range partition's candidates — strict chunks
1791
+ // toward the target sized by real density (up to the pending-chunk cap), or,
1792
+ // for a partition without a trusted positive density, a single open-ended
1793
+ // probe at its even share of the budget. No budget check here; the
1794
+ // acceptance pass below decides which candidates make the cut.
1795
+ //
1796
+ // Chunks require a POSITIVE trusted density: density 0 prices every chunk at
1797
+ // ~nothing, so an open-ended probe (full server scan range in one response)
1798
+ // beats a pipeline of hard-bounded chunks that crawl 1.8× per two responses.
1799
+ inRangeStates->Array.forEach(fs => {
1800
+ let p = fs.p
1801
+ let maxBlock = switch fs.queryEndBlock {
1802
+ | Some(eb) => eb
1803
+ | None => chainTargetBlock
1804
+ }
1805
+ switch (fs.maybeChunkRange, p->getTrustedDensity) {
1806
+ | (Some(minHistoryRange), Some(density)) if density > 0. =>
1807
+ let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1808
+ let maxChunksRemaining =
1809
+ maxPendingChunksPerPartition - fs.pendingCount - fs.chunksUsedThisCall
1810
+ // No chunk starts past chainTargetBlock; an emitted chunk still keeps
1811
+ // its full span (chunkToBlock may exceed the target — only
1812
+ // endBlock/mergeBlock are hard bounds).
1813
+ let chunkStartCeiling = Pervasives.min(maxBlock, chainTargetBlock)
1814
+ let created = ref(0)
1815
+ let chunkFromBlock = ref(fs.cursor)
1816
+ // Stop once this partition alone has generated more than the whole fresh
1817
+ // budget: the acceptance pass can hand a single partition at most the
1818
+ // budget plus one overshoot query, so further chunks could never be
1819
+ // accepted. Bounds generation (and the candidate sort) when the budget
1820
+ // is small relative to the pending-chunk cap.
1821
+ let generatedItems = ref(0.)
1822
+ while (
1823
+ created.contents < maxChunksRemaining &&
1824
+ chunkFromBlock.contents <= chunkStartCeiling &&
1825
+ generatedItems.contents <= freshBudget
1826
+ ) {
1827
+ let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1828
+ let rawEst = density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat
1829
+ let itemsEst = Pervasives.max(1, rawEst->Math.ceil->Float.toInt)
1830
+ let itemsTarget = Pervasives.max(
1831
+ 1,
1832
+ (rawEst *. chunkItemsMultiplier)->Math.ceil->Float.toInt,
1833
+ )
1834
+ candidates
1835
+ ->Array.push({
1836
+ partitionId: fs.partitionId,
1837
+ fromBlock: chunkFromBlock.contents,
1838
+ toBlock: Some(chunkToBlock),
1839
+ isChunk: true,
1840
+ selection: p.selection,
1841
+ itemsTarget,
1842
+ itemsEst,
1843
+ addressesByContractName: p.addressesByContractName,
1844
+ })
1845
+ ->ignore
1846
+ generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
1847
+ chunkFromBlock := chunkToBlock + 1
1848
+ created := created.contents + 1
1849
+ }
1850
+ | _ =>
1851
+ // Size the probe by the events its range to the target is expected to
1852
+ // hold — rangeTargetDensity × (chainTargetBlock − fromBlock + 1), split
1853
+ // across the partitions fetching this tick. With no range to the target
1854
+ // fall back to an even share of the fresh budget, so cold chains and
1855
+ // caught-up partitions still probe.
1856
+ let itemsTarget = if rangeToTarget > 0 {
1857
+ Pervasives.max(
1858
+ 1,
1859
+ Math.round(
1860
+ rangeTargetDensity *.
1861
+ (chainTargetBlock - fs.cursor + 1)->Int.toFloat /.
1862
+ inRangeCount->Int.toFloat,
1863
+ )->Float.toInt,
1864
+ )
1865
+ } else {
1866
+ Pervasives.max(1, Math.round(probeShare)->Float.toInt)
1867
+ }
1868
+ candidates
1869
+ ->Array.push({
1870
+ partitionId: fs.partitionId,
1871
+ fromBlock: fs.cursor,
1872
+ toBlock: fs.queryEndBlock,
1873
+ isChunk: false,
1874
+ selection: p.selection,
1875
+ itemsTarget,
1876
+ itemsEst: itemsTarget,
1877
+ addressesByContractName: p.addressesByContractName,
1878
+ })
1580
1879
  ->ignore
1581
1880
  }
1881
+ })
1582
1882
 
1583
- idxRef := idxRef.contents + 1
1883
+ // Acceptance: merge fresh candidates (Some) with the in-flight reservations
1884
+ // (None) and walk them in fromBlock order, starting from the full
1885
+ // chainTargetItems. A reservation just draws down the budget — its query is
1886
+ // already sent — while a candidate draws down the budget and is emitted.
1887
+ // Because a gap-fill's fromBlock precedes the in-flight query it unblocks,
1888
+ // it claims budget ahead of that reservation, so the buffer never deadlocks
1889
+ // waiting on a hole it can't fund. The candidate that tips the budget
1890
+ // negative is still emitted (a single overshoot); everything after it waits
1891
+ // for a tick with more budget. Accepted queries route back to their
1892
+ // partition bucket, so the output stays in idsInAscOrder with each
1893
+ // partition's queries in fromBlock order.
1894
+ let acceptanceStream = []
1895
+ candidates->Array.forEach(query =>
1896
+ acceptanceStream->Array.push((query.fromBlock, query.itemsEst, Some(query)))->ignore
1897
+ )
1898
+ reservations->Array.forEach(((fromBlock, itemsEst)) =>
1899
+ acceptanceStream->Array.push((fromBlock, itemsEst, None))->ignore
1900
+ )
1901
+ // Sort by fromBlock; on a tie charge the in-flight reservation (None) before
1902
+ // a fresh candidate (Some), so a same-block candidate can't overshoot the
1903
+ // target buffer. Only a strictly-earlier candidate — a gap-fill, whose
1904
+ // fromBlock precedes the query it unblocks — borrows ahead of a reservation.
1905
+ acceptanceStream->Array.sort(((aFrom, _, aQuery), (bFrom, _, bQuery)) =>
1906
+ if aFrom !== bFrom {
1907
+ Int.compare(aFrom, bFrom)
1908
+ } else {
1909
+ switch (aQuery, bQuery) {
1910
+ | (None, Some(_)) => Ordering.less
1911
+ | (Some(_), None) => Ordering.greater
1912
+ | (None, None) | (Some(_), Some(_)) => Ordering.equal
1913
+ }
1914
+ }
1915
+ )
1916
+ let streamCount = acceptanceStream->Array.length
1917
+ let remainingBudget = ref(chainTargetItems)
1918
+ let acceptIdx = ref(0)
1919
+ while remainingBudget.contents > 0. && acceptIdx.contents < streamCount {
1920
+ let (_, itemsEst, maybeQuery) = acceptanceStream->Array.getUnsafe(acceptIdx.contents)
1921
+ switch maybeQuery {
1922
+ | Some(query) =>
1923
+ let partitionIdx = partitionIndexById->Dict.getUnsafe(query.partitionId)
1924
+ queriesByPartitionIndex->Array.getUnsafe(partitionIdx)->Array.push(query)->ignore
1925
+ | None => ()
1926
+ }
1927
+ remainingBudget := remainingBudget.contents -. itemsEst->Int.toFloat
1928
+ acceptIdx := acceptIdx.contents + 1
1584
1929
  }
1585
1930
 
1931
+ let queries = queriesByPartitionIndex->Array.flat
1932
+
1586
1933
  if queries->Utils.Array.isEmpty {
1587
1934
  if shouldWaitForNewBlock.contents {
1588
1935
  WaitingForNewBlock
@@ -1631,14 +1978,15 @@ Instantiates a fetch state with partitions for initial addresses
1631
1978
  let make = (
1632
1979
  ~startBlock,
1633
1980
  ~endBlock,
1634
- ~eventConfigs: array<Internal.eventConfig>,
1981
+ ~onEventRegistrations: array<Internal.onEventRegistration>,
1982
+ ~contractConfigs: dict<IndexingAddresses.contractConfig>,
1635
1983
  ~addresses: array<Internal.indexingAddress>,
1636
1984
  ~maxAddrInPartition,
1637
1985
  ~chainId,
1638
1986
  ~maxOnBlockBufferSize,
1639
1987
  ~knownHeight,
1640
1988
  ~progressBlockNumber=startBlock - 1,
1641
- ~onBlockConfigs=[],
1989
+ ~onBlockRegistrations=[],
1642
1990
  ~blockLag=0,
1643
1991
  ~firstEventBlock=None,
1644
1992
  ): t => {
@@ -1648,38 +1996,15 @@ let make = (
1648
1996
  }
1649
1997
 
1650
1998
  let notDependingOnAddresses = []
1651
- let normalEventConfigs = []
1999
+ let normalRegistrations = []
1652
2000
  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
2001
 
1678
- if ec.dependsOnAddresses {
1679
- normalEventConfigs->Array.push(ec)
1680
- contractNamesWithNormalEvents->Utils.Set.add(ec.contractName)->ignore
2002
+ onEventRegistrations->Array.forEach(reg => {
2003
+ if reg.dependsOnAddresses {
2004
+ normalRegistrations->Array.push(reg)
2005
+ contractNamesWithNormalEvents->Utils.Set.add(reg.eventConfig.contractName)->ignore
1681
2006
  } else {
1682
- notDependingOnAddresses->Array.push(ec)
2007
+ notDependingOnAddresses->Array.push(reg)
1683
2008
  }
1684
2009
  })
1685
2010
 
@@ -1691,7 +2016,7 @@ let make = (
1691
2016
  latestFetchedBlock,
1692
2017
  selection: {
1693
2018
  dependsOnAddresses: false,
1694
- eventConfigs: notDependingOnAddresses,
2019
+ onEventRegistrations: notDependingOnAddresses,
1695
2020
  },
1696
2021
  addressesByContractName: Dict.make(),
1697
2022
  mergeBlock: None,
@@ -1706,7 +2031,7 @@ let make = (
1706
2031
 
1707
2032
  let normalSelection = {
1708
2033
  dependsOnAddresses: true,
1709
- eventConfigs: normalEventConfigs,
2034
+ onEventRegistrations: normalRegistrations,
1710
2035
  }
1711
2036
 
1712
2037
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
@@ -1714,32 +2039,16 @@ let make = (
1714
2039
 
1715
2040
  addresses->Array.forEach(contract => {
1716
2041
  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
2042
 
1737
2043
  // Only addresses whose contract has events that depend on addresses get
1738
2044
  // registered for active fetching via partitions.
1739
2045
  if contractNamesWithNormalEvents->Utils.Set.has(contractName) {
1740
- let registeringContracts =
1741
- registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1742
- registeringContracts->Dict.set(contract.address->Address.toString, ia)
2046
+ registeringContractsByContract
2047
+ ->Utils.Dict.getOrInsertEmptyDict(contractName)
2048
+ ->Dict.set(
2049
+ contract.address->Address.toString,
2050
+ IndexingAddresses.makeIndexingAddress(~contract, ~contractConfigs),
2051
+ )
1743
2052
 
1744
2053
  // Detect dynamic contracts by registrationBlock
1745
2054
  if contract.registrationBlock !== -1 {
@@ -1758,12 +2067,15 @@ let make = (
1758
2067
  ~progressBlockNumber,
1759
2068
  )
1760
2069
 
1761
- if optimizedPartitions->OptimizedPartitions.count === 0 && onBlockConfigs->Utils.Array.isEmpty {
2070
+ if (
2071
+ optimizedPartitions->OptimizedPartitions.count === 0 &&
2072
+ onBlockRegistrations->Utils.Array.isEmpty
2073
+ ) {
1762
2074
  JsError.throwWithMessage(
1763
2075
  `Invalid configuration: Nothing to fetch on chain ${chainId->Int.toString}. ` ++
1764
2076
  `addresses=${addresses->Array.length->Int.toString}, ` ++
1765
- `eventConfigs=${eventConfigs->Array.length->Int.toString}, ` ++
1766
- `normalEventConfigs=${normalEventConfigs
2077
+ `onEventRegistrations=${onEventRegistrations->Array.length->Int.toString}, ` ++
2078
+ `normalRegistrations=${normalRegistrations
1767
2079
  ->Array.length
1768
2080
  ->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
2081
  )
@@ -1774,14 +2086,14 @@ let make = (
1774
2086
  // fetching, so without seeding the buffer here getNextQuery would return
1775
2087
  // NothingToQuery and the indexer would get stuck.
1776
2088
  let buffer = []
1777
- let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockConfigs->Utils.Array.notEmpty {
2089
+ let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockRegistrations->Utils.Array.notEmpty {
1778
2090
  let maxBlockNumber = switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
1779
2091
  | None => knownHeight
1780
2092
  | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
1781
2093
  }
1782
2094
  appendOnBlockItems(
1783
2095
  ~mutItems=buffer,
1784
- ~onBlockConfigs,
2096
+ ~onBlockRegistrations,
1785
2097
  ~indexerStartBlock=startBlock,
1786
2098
  ~fromBlock=progressBlockNumber,
1787
2099
  ~maxBlockNumber,
@@ -1799,17 +2111,14 @@ let make = (
1799
2111
  endBlock,
1800
2112
  latestOnBlockBlockNumber,
1801
2113
  normalSelection,
1802
- indexingAddresses,
1803
2114
  blockLag,
1804
- onBlockConfigs,
2115
+ onBlockRegistrations,
1805
2116
  maxOnBlockBufferSize,
1806
2117
  knownHeight,
1807
2118
  buffer,
1808
2119
  firstEventBlock,
1809
2120
  }
1810
2121
 
1811
- let numAddresses = indexingAddresses->Utils.Dict.size
1812
- Prometheus.IndexingAddresses.set(~addressesCount=numAddresses, ~chainId)
1813
2122
  Prometheus.IndexingPartitions.set(
1814
2123
  ~partitionsCount=optimizedPartitions->OptimizedPartitions.count,
1815
2124
  ~chainId,
@@ -1826,25 +2135,6 @@ let make = (
1826
2135
 
1827
2136
  let bufferSize = ({buffer}: t) => buffer->Array.length
1828
2137
 
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
2138
  let rollbackPendingQueries = (mutPendingQueries: array<pendingQuery>, ~targetBlockNumber) => {
1849
2139
  // - Remove queries where fromBlock > target
1850
2140
  // - Cap fetchedBlock at target where fetchedBlock > target
@@ -1876,18 +2166,11 @@ Always recreates optimized partitions to avoid duplicate addresses:
1876
2166
  - Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
1877
2167
  - Non-wildcard with lfb > target: delete, track addresses for recreation
1878
2168
  */
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
- })
2169
+ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetBlockNumber) => {
2170
+ // Step 1: Prune addresses registered after the target block. The pruned index is
2171
+ // then the source of truth for partition cleanup below — an address survives iff
2172
+ // it's still in the index.
2173
+ indexingAddresses->IndexingAddresses.rollbackInPlace(~targetBlockNumber)
1891
2174
 
1892
2175
  // Step 2: Categorize partitions
1893
2176
  let keptPartitions = []
@@ -1917,18 +2200,12 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1917
2200
  | _ if p.latestFetchedBlock.blockNumber > targetBlockNumber =>
1918
2201
  p.addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1919
2202
  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
- ) {
2203
+ switch indexingAddresses->IndexingAddresses.get(address->Address.toString) {
2204
+ | Some(indexingContract) =>
1926
2205
  let registeringContracts =
1927
2206
  registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
1928
- registeringContracts->Dict.set(
1929
- address->Address.toString,
1930
- indexingAddresses->Dict.getUnsafe(address->Address.toString),
1931
- )
2207
+ registeringContracts->Dict.set(address->Address.toString, indexingContract)
2208
+ | None => ()
1932
2209
  }
1933
2210
  })
1934
2211
  })
@@ -1941,11 +2218,13 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1941
2218
  | other => other
1942
2219
  }
1943
2220
 
1944
- // Remove addresses that should be removed
2221
+ // Drop addresses pruned from the index
1945
2222
  let rollbackedAddressesByContractName = Dict.make()
1946
2223
  addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
1947
2224
  let keptAddresses =
1948
- addresses->Array.filter(address => !(addressesToRemove->Utils.Set.has(address)))
2225
+ addresses->Array.filter(address =>
2226
+ indexingAddresses->IndexingAddresses.get(address->Address.toString)->Option.isSome
2227
+ )
1949
2228
  if keptAddresses->Array.length > 0 {
1950
2229
  rollbackedAddressesByContractName->Dict.set(contractName, keptAddresses)
1951
2230
  }
@@ -1989,7 +2268,8 @@ let rollback = (fetchState: t, ~targetBlockNumber) => {
1989
2268
  ),
1990
2269
  }->updateInternal(
1991
2270
  ~optimizedPartitions,
1992
- ~indexingAddresses,
2271
+ // Filtering the sorted buffer keeps it sorted and deduped.
2272
+ ~mutItemsSorted=true,
1993
2273
  ~mutItems=fetchState.buffer->Array.filter(item =>
1994
2274
  switch item {
1995
2275
  | Event({blockNumber})
@@ -2084,17 +2364,6 @@ let getProgressPercentage = (fetchState: t) => {
2084
2364
  }
2085
2365
  }
2086
2366
 
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
2367
  let sortForBatch = {
2099
2368
  let hasFullBatch = ({buffer} as fetchState: t, ~batchSizeTarget) => {
2100
2369
  switch buffer->Array.get(batchSizeTarget - 1) {