envio 3.3.0-alpha.10 → 3.3.0-alpha.12

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 (60) hide show
  1. package/licenses/CLA.md +35 -0
  2. package/licenses/EULA.md +67 -0
  3. package/licenses/LICENSE.md +45 -0
  4. package/licenses/README.md +35 -0
  5. package/package.json +9 -8
  6. package/src/Batch.res.mjs +1 -1
  7. package/src/ChainFetching.res +30 -18
  8. package/src/ChainFetching.res.mjs +23 -13
  9. package/src/ChainState.res +45 -30
  10. package/src/ChainState.res.mjs +22 -8
  11. package/src/ChainState.resi +5 -0
  12. package/src/Config.res +9 -5
  13. package/src/Config.res.mjs +2 -2
  14. package/src/Core.res +20 -0
  15. package/src/Core.res.mjs +12 -0
  16. package/src/CrossChainState.res +64 -29
  17. package/src/CrossChainState.res.mjs +17 -8
  18. package/src/EventConfigBuilder.res +21 -46
  19. package/src/EventConfigBuilder.res.mjs +18 -25
  20. package/src/EventProcessing.res +8 -5
  21. package/src/EventProcessing.res.mjs +2 -1
  22. package/src/FetchState.res +428 -322
  23. package/src/FetchState.res.mjs +316 -233
  24. package/src/HandlerRegister.res +3 -1
  25. package/src/HandlerRegister.res.mjs +3 -2
  26. package/src/Internal.res +11 -2
  27. package/src/LogSelection.res +0 -62
  28. package/src/LogSelection.res.mjs +0 -74
  29. package/src/RawEvent.res +2 -2
  30. package/src/Rollback.res +32 -13
  31. package/src/Rollback.res.mjs +24 -11
  32. package/src/SimulateDeadInputTracker.res +1 -1
  33. package/src/SimulateItems.res +38 -6
  34. package/src/SimulateItems.res.mjs +28 -9
  35. package/src/TestIndexer.res +2 -2
  36. package/src/TestIndexer.res.mjs +2 -2
  37. package/src/TopicFilter.res +1 -25
  38. package/src/TopicFilter.res.mjs +0 -74
  39. package/src/bindings/Viem.res +0 -5
  40. package/src/sources/EventRouter.res +0 -21
  41. package/src/sources/EventRouter.res.mjs +0 -12
  42. package/src/sources/EvmChain.res +2 -27
  43. package/src/sources/EvmChain.res.mjs +2 -23
  44. package/src/sources/EvmRpcClient.res +12 -15
  45. package/src/sources/EvmRpcClient.res.mjs +3 -3
  46. package/src/sources/HyperSync.res +9 -38
  47. package/src/sources/HyperSync.res.mjs +16 -28
  48. package/src/sources/HyperSync.resi +2 -2
  49. package/src/sources/HyperSyncClient.res +88 -11
  50. package/src/sources/HyperSyncClient.res.mjs +39 -6
  51. package/src/sources/HyperSyncSource.res +18 -199
  52. package/src/sources/HyperSyncSource.res.mjs +9 -128
  53. package/src/sources/Rpc.res +0 -32
  54. package/src/sources/Rpc.res.mjs +1 -46
  55. package/src/sources/RpcSource.res +29 -175
  56. package/src/sources/RpcSource.res.mjs +32 -110
  57. package/src/sources/SimulateSource.res +37 -19
  58. package/src/sources/SimulateSource.res.mjs +27 -10
  59. package/src/sources/SvmHyperSyncSource.res +13 -3
  60. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
@@ -47,17 +47,19 @@ type partition = {
47
47
  dynamicContract: option<string>,
48
48
  // Mutable array for SourceManager sync - queries exist only while being fetched
49
49
  mutPendingQueries: array<pendingQuery>,
50
- // Track last 3 successful query ranges for chunking heuristic (0 means no data)
51
- prevQueryRange: int,
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
+ // The last two ranges that measured how many blocks the source could return.
51
+ // Both must be non-zero before the minimum is trusted for chunk sizing.
52
+ sourceRangeCapacity: int,
53
+ prevSourceRangeCapacity: int,
54
+ // Smoothed items/block observed in responses. This is independent from the
55
+ // source's range capacity: even a response truncated by our own itemsTarget
56
+ // cap is useful density evidence while saying nothing about source capacity.
57
+ // None distinguishes a new partition from a real zero-density observation.
58
+ eventDensity: option<float>,
57
59
  // Tracks the latestFetchedBlock.blockNumber of the most recent response
58
- // that updated prevQueryRange. Prevents degradation of the chunking heuristic
59
- // when parallel query responses arrive out of order.
60
- latestBlockRangeUpdateBlock: int,
60
+ // that updated sourceRangeCapacity. Prevents degradation of the chunking
61
+ // heuristic when parallel query responses arrive out of order.
62
+ latestSourceRangeCapacityUpdateBlock: int,
61
63
  }
62
64
 
63
65
  type query = {
@@ -111,22 +113,17 @@ let densityItemsTarget = (~density, ~fromBlock, ~toBlock, ~chainTargetBlock) =>
111
113
  )
112
114
  }
113
115
 
114
- // Calculate the chunk range from history using min-of-last-3-ranges heuristic
116
+ // Calculate the chunk range from the last two source-capacity observations.
115
117
  let getMinHistoryRange = (p: partition) => {
116
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
118
+ switch (p.sourceRangeCapacity, p.prevSourceRangeCapacity) {
117
119
  | (0, _) | (_, 0) => None
118
120
  | (a, b) => Some(a < b ? a : b)
119
121
  }
120
122
  }
121
123
 
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
- }
124
+ // Density has its own initialization signal and is useful independently from
125
+ // source-capacity history, including when an itemsTarget cap truncates a response.
126
+ let getTrustedDensity = (p: partition) => p.eventDensity
130
127
 
131
128
  let getMinQueryRange = (partitions: array<partition>) => {
132
129
  let min = ref(0)
@@ -134,8 +131,8 @@ let getMinQueryRange = (partitions: array<partition>) => {
134
131
  let p = partitions->Array.getUnsafe(i)
135
132
 
136
133
  // Even if it's fetching, set dynamicContract field
137
- let a = p.prevQueryRange
138
- let b = p.prevPrevQueryRange
134
+ let a = p.sourceRangeCapacity
135
+ let b = p.prevSourceRangeCapacity
139
136
  if a > 0 && (min.contents == 0 || a < min.contents) {
140
137
  min := a
141
138
  }
@@ -208,11 +205,14 @@ module OptimizedPartitions = {
208
205
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
209
206
  let minRange = getMinQueryRange([p1, p2])
210
207
  // 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.)
208
+ // event rate is the sum of their densities. If neither parent has a
209
+ // trusted density, the merged partition probes for a fresh signal
210
+ // instead of treating zero as an observed density.
211
+ let inheritedDensity = switch (p1->getTrustedDensity, p2->getTrustedDensity) {
212
+ | (Some(p1Density), Some(p2Density)) => Some(p1Density +. p2Density)
213
+ | (Some(density), None) | (None, Some(density)) => Some(density)
214
+ | (None, None) => None
215
+ }
216
216
  {
217
217
  id: newId,
218
218
  dynamicContract: Some(contractName),
@@ -221,10 +221,10 @@ module OptimizedPartitions = {
221
221
  mergeBlock: None,
222
222
  addressesByContractName: Dict.make(), // set below
223
223
  mutPendingQueries: [],
224
- prevQueryRange: minRange,
225
- prevPrevQueryRange: minRange,
226
- prevRangeSize: (inheritedDensity *. minRange->Int.toFloat)->Math.ceil->Float.toInt,
227
- latestBlockRangeUpdateBlock: 0,
224
+ sourceRangeCapacity: minRange,
225
+ prevSourceRangeCapacity: minRange,
226
+ eventDensity: inheritedDensity,
227
+ latestSourceRangeCapacityUpdateBlock: 0,
228
228
  }
229
229
  }
230
230
 
@@ -472,14 +472,28 @@ module OptimizedPartitions = {
472
472
  pendingQuery.fetchedBlock = Some(latestFetchedBlock)
473
473
 
474
474
  let blockRange = latestFetchedBlock.blockNumber - query.fromBlock + 1
475
- // Skip updating block range if a later response already updated it.
475
+ // Update density for every response, independently from whether this range
476
+ // is valid evidence of source capacity. A cap hit is still useful density
477
+ // evidence because it reports items returned across the scanned range.
478
+ let observedEventDensity = itemsCount->Int.toFloat /. blockRange->Int.toFloat
479
+ // Seed from the first observation, then smooth every later observation
480
+ // with a 1:1 moving average. Keeping initialization explicit is important:
481
+ // zero is valid density evidence and must participate in the next blend.
482
+ let updatedEventDensity = switch p.eventDensity {
483
+ | None => Some(observedEventDensity)
484
+ | Some(eventDensity) => Some((eventDensity +. observedEventDensity) /. 2.)
485
+ }
486
+
487
+ // Skip updating source capacity if a later response already updated it.
476
488
  // Prevents degradation of the chunking heuristic when parallel query
477
489
  // responses arrive out of order (e.g. earlier query with smaller range
478
490
  // arriving after a later query with bigger range).
479
- let shouldUpdateBlockRange =
480
- latestFetchedBlock.blockNumber > p.latestBlockRangeUpdateBlock &&
491
+ let shouldUpdateSourceRangeCapacity =
492
+ latestFetchedBlock.blockNumber > p.latestSourceRangeCapacityUpdateBlock &&
481
493
  switch query.toBlock {
482
- | None => latestFetchedBlock.blockNumber < knownHeight - 10 // Don't update block range when very close to the head
494
+ | None =>
495
+ // Don't update source capacity when very close to the head.
496
+ latestFetchedBlock.blockNumber < knownHeight - 10
483
497
  | Some(queryToBlock) =>
484
498
  if latestFetchedBlock.blockNumber < queryToBlock {
485
499
  // Partial response is direct capacity evidence — unless it was
@@ -497,9 +511,12 @@ module OptimizedPartitions = {
497
511
  }
498
512
  }
499
513
  }
500
- let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
501
- let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
502
- let updatedPrevRangeSize = shouldUpdateBlockRange ? itemsCount : p.prevRangeSize
514
+ let updatedSourceRangeCapacity = shouldUpdateSourceRangeCapacity
515
+ ? blockRange
516
+ : p.sourceRangeCapacity
517
+ let updatedPrevSourceRangeCapacity = shouldUpdateSourceRangeCapacity
518
+ ? p.sourceRangeCapacity
519
+ : p.prevSourceRangeCapacity
503
520
 
504
521
  // Process fetched queries from front of queue for main partition
505
522
  let updatedLatestFetchedBlock = consumeFetchedQueries(
@@ -519,12 +536,12 @@ module OptimizedPartitions = {
519
536
  let updatedMainPartition = {
520
537
  ...p,
521
538
  latestFetchedBlock: updatedLatestFetchedBlock,
522
- prevQueryRange: updatedPrevQueryRange,
523
- prevPrevQueryRange: updatedPrevPrevQueryRange,
524
- prevRangeSize: updatedPrevRangeSize,
525
- latestBlockRangeUpdateBlock: shouldUpdateBlockRange
539
+ sourceRangeCapacity: updatedSourceRangeCapacity,
540
+ prevSourceRangeCapacity: updatedPrevSourceRangeCapacity,
541
+ eventDensity: updatedEventDensity,
542
+ latestSourceRangeCapacityUpdateBlock: shouldUpdateSourceRangeCapacity
526
543
  ? latestFetchedBlock.blockNumber
527
- : p.latestBlockRangeUpdateBlock,
544
+ : p.latestSourceRangeCapacityUpdateBlock,
528
545
  }
529
546
 
530
547
  mutEntities->Dict.set(p.id, updatedMainPartition)
@@ -629,13 +646,90 @@ let bufferReadyCount = (fetchState: t) => {
629
646
  /*
630
647
  Comparitor for two events from the same chain. No need for chain id or timestamp
631
648
  */
632
- let compareBufferItem = (a: Internal.item, b: Internal.item) => {
633
- let blockOrdering = Int.compare(a->Internal.getItemBlockNumber, b->Internal.getItemBlockNumber)
634
- if blockOrdering === Ordering.equal {
635
- Int.compare(a->Internal.getItemLogIndex, b->Internal.getItemLogIndex)
649
+ let getRegistrationIndex = (item: Internal.item): int =>
650
+ switch item {
651
+ | Event({onEventRegistration}) => onEventRegistration.index
652
+ | Block({onBlockRegistration}) => onBlockRegistration.index
653
+ }
654
+
655
+ // Total order on buffer items: block, then logIndex, then registration index.
656
+ // Returns a plain int (-1/0/1) with explicit field comparisons so it can be
657
+ // called directly from the merge/insertion loops below — no Array.sort callback,
658
+ // no allocated key. `0` means a true duplicate: same log routed to the same
659
+ // registration (two registrations for one log differ by index and are kept).
660
+ let compareBufferItem = (a: Internal.item, b: Internal.item): int => {
661
+ let ba = a->Internal.getItemBlockNumber
662
+ let bb = b->Internal.getItemBlockNumber
663
+ if ba != bb {
664
+ ba < bb ? -1 : 1
636
665
  } else {
637
- blockOrdering
666
+ let la = a->Internal.getItemLogIndex
667
+ let lb = b->Internal.getItemLogIndex
668
+ if la != lb {
669
+ la < lb ? -1 : 1
670
+ } else {
671
+ let ia = a->getRegistrationIndex
672
+ let ib = b->getRegistrationIndex
673
+ ia < ib ? -1 : ia > ib ? 1 : 0
674
+ }
675
+ }
676
+ }
677
+
678
+ // Merge a maybe-unsorted `newItems` run into the already-sorted, already-deduped
679
+ // `buffer`, dropping items equal on (blockNumber, logIndex, registration index).
680
+ // Single linear pass over both runs after ordering `newItems` in place; every
681
+ // comparison is a direct `compareBufferItem` call (V8 inlines it) rather than a
682
+ // callback through `Array.sort`.
683
+ let mergeIntoBuffer = (buffer: array<Internal.item>, newItems: array<Internal.item>): array<
684
+ Internal.item,
685
+ > => {
686
+ let n = newItems->Array.length
687
+ // Insertion sort: a source response is small and usually already ascending,
688
+ // so this is ~O(n) here.
689
+ for i in 1 to n - 1 {
690
+ let x = newItems->Array.getUnsafe(i)
691
+ let j = ref(i - 1)
692
+ while j.contents >= 0 && compareBufferItem(newItems->Array.getUnsafe(j.contents), x) > 0 {
693
+ newItems->Array.setUnsafe(j.contents + 1, newItems->Array.getUnsafe(j.contents))
694
+ j := j.contents - 1
695
+ }
696
+ newItems->Array.setUnsafe(j.contents + 1, x)
697
+ }
698
+
699
+ let m = buffer->Array.length
700
+ let merged = []
701
+ let last = ref(None)
702
+ let push = item =>
703
+ switch last.contents {
704
+ | Some(l) if compareBufferItem(l, item) === 0 => ()
705
+ | _ => {
706
+ merged->Array.push(item)
707
+ last := Some(item)
708
+ }
709
+ }
710
+
711
+ let i = ref(0)
712
+ let j = ref(0)
713
+ while i.contents < m && j.contents < n {
714
+ let a = buffer->Array.getUnsafe(i.contents)
715
+ let b = newItems->Array.getUnsafe(j.contents)
716
+ if compareBufferItem(a, b) <= 0 {
717
+ push(a)
718
+ i := i.contents + 1
719
+ } else {
720
+ push(b)
721
+ j := j.contents + 1
722
+ }
723
+ }
724
+ while i.contents < m {
725
+ push(buffer->Array.getUnsafe(i.contents))
726
+ i := i.contents + 1
727
+ }
728
+ while j.contents < n {
729
+ push(newItems->Array.getUnsafe(j.contents))
730
+ j := j.contents + 1
638
731
  }
732
+ merged
639
733
  }
640
734
 
641
735
  // Some big number which should be bigger than any log index
@@ -706,48 +800,46 @@ let updateInternal = (
706
800
  fetchState: t,
707
801
  ~optimizedPartitions=fetchState.optimizedPartitions,
708
802
  ~mutItems=?,
803
+ // Set when the caller already passes a sorted, deduped buffer (hot paths merge
804
+ // via mergeIntoBuffer or filter the sorted buffer). Otherwise mutItems is
805
+ // normalized here, so callers can hand over items in any order.
806
+ ~mutItemsSorted=false,
709
807
  ~blockLag=fetchState.blockLag,
710
808
  ~knownHeight=fetchState.knownHeight,
711
809
  ): t => {
712
- let mutItemsRef = ref(mutItems)
810
+ // The buffer to build on: the caller's items (normalized to sorted if needed),
811
+ // or the current buffer when only onBlock items change.
812
+ let base = switch mutItems {
813
+ | Some(items) => mutItemsSorted ? items : []->mergeIntoBuffer(items)
814
+ | None => fetchState.buffer
815
+ }
713
816
 
817
+ // onBlock items are generated as their own ascending (block, logIndex) run and
818
+ // folded into `base` by the single merge below.
819
+ let blockItems = []
714
820
  let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
715
821
  | [] => knownHeight
716
- | onBlockRegistrations => {
717
- // Calculate the max block number we are going to create items for
718
- // Use maxOnBlockBufferSize to get the last target item in the buffer
719
- //
720
- // mutItems is not very reliable, since it might not be sorted,
721
- // but the chances for it happen are very low and not critical
722
- //
723
- // All this needed to prevent OOM when adding too many block items to the queue
724
- let maxBlockNumber = switch switch mutItemsRef.contents {
725
- | Some(mutItems) => mutItems
726
- | None => fetchState.buffer
727
- }->Array.get(fetchState.maxOnBlockBufferSize - 1) {
728
- | Some(item) => item->Internal.getItemBlockNumber
729
- | None =>
730
- switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
731
- | None => knownHeight
732
- | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
733
- }
734
- }
735
-
736
- let mutItems = switch mutItemsRef.contents {
737
- | Some(mutItems) => mutItems
738
- | None => fetchState.buffer->Array.copy
822
+ | onBlockRegistrations =>
823
+ // Calculate the max block number we are going to create items for
824
+ // Use maxOnBlockBufferSize to get the last target item in the buffer
825
+ // (sorted, so this is the highest-block item within the buffer cap).
826
+ // All this needed to prevent OOM when adding too many block items to the queue
827
+ let maxBlockNumber = switch base->Array.get(fetchState.maxOnBlockBufferSize - 1) {
828
+ | Some(item) => item->Internal.getItemBlockNumber
829
+ | None =>
830
+ switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
831
+ | None => knownHeight
832
+ | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
739
833
  }
740
- mutItemsRef := Some(mutItems)
741
-
742
- appendOnBlockItems(
743
- ~mutItems,
744
- ~onBlockRegistrations,
745
- ~indexerStartBlock=fetchState.startBlock,
746
- ~fromBlock=fetchState.latestOnBlockBlockNumber,
747
- ~maxBlockNumber,
748
- ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
749
- )
750
834
  }
835
+ appendOnBlockItems(
836
+ ~mutItems=blockItems,
837
+ ~onBlockRegistrations,
838
+ ~indexerStartBlock=fetchState.startBlock,
839
+ ~fromBlock=fetchState.latestOnBlockBlockNumber,
840
+ ~maxBlockNumber,
841
+ ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
842
+ )
751
843
  }
752
844
 
753
845
  let updatedFetchState = {
@@ -762,15 +854,10 @@ let updateInternal = (
762
854
  latestOnBlockBlockNumber,
763
855
  blockLag,
764
856
  knownHeight,
765
- buffer: switch mutItemsRef.contents {
766
- // Theoretically it could be faster to asume that
767
- // the items are sorted, but there are cases
768
- // when the data source returns them unsorted
769
- | Some(mutItems) => {
770
- mutItems->Array.sort(compareBufferItem)
771
- mutItems
772
- }
773
- | None => fetchState.buffer
857
+ // Single merge point: fold any onBlock items into the sorted base buffer.
858
+ buffer: switch blockItems {
859
+ | [] => base
860
+ | blockItems => base->mergeIntoBuffer(blockItems)
774
861
  },
775
862
  firstEventBlock: fetchState.firstEventBlock,
776
863
  }
@@ -916,10 +1003,10 @@ OptimizedPartitions.t => {
916
1003
  addressesByContractName,
917
1004
  mergeBlock: None,
918
1005
  mutPendingQueries: [],
919
- prevQueryRange: 0,
920
- prevPrevQueryRange: 0,
921
- prevRangeSize: 0,
922
- latestBlockRangeUpdateBlock: 0,
1006
+ sourceRangeCapacity: 0,
1007
+ prevSourceRangeCapacity: 0,
1008
+ eventDensity: None,
1009
+ latestSourceRangeCapacityUpdateBlock: 0,
923
1010
  })
924
1011
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
925
1012
  }
@@ -1252,10 +1339,10 @@ let registerDynamicContracts = (
1252
1339
  addressesByContractName,
1253
1340
  mergeBlock: None,
1254
1341
  mutPendingQueries: p.mutPendingQueries,
1255
- prevQueryRange: p.prevQueryRange,
1256
- prevPrevQueryRange: p.prevPrevQueryRange,
1257
- prevRangeSize: p.prevRangeSize,
1258
- latestBlockRangeUpdateBlock: p.latestBlockRangeUpdateBlock,
1342
+ sourceRangeCapacity: p.sourceRangeCapacity,
1343
+ prevSourceRangeCapacity: p.prevSourceRangeCapacity,
1344
+ eventDensity: p.eventDensity,
1345
+ latestSourceRangeCapacityUpdateBlock: p.latestSourceRangeCapacityUpdateBlock,
1259
1346
  })
1260
1347
  }
1261
1348
  }
@@ -1287,6 +1374,26 @@ let registerDynamicContracts = (
1287
1374
  }
1288
1375
  }
1289
1376
 
1377
+ // Drop events an address-param filter rejects. A merged partition may
1378
+ // over-fetch a wildcard event whose indexed address param references an
1379
+ // address registered after the log's block; `clientAddressFilter` is the
1380
+ // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1381
+ let filterByClientAddress = (
1382
+ items: array<Internal.item>,
1383
+ ~indexingAddresses: IndexingAddresses.t,
1384
+ ): array<Internal.item> =>
1385
+ items->Array.filter(item =>
1386
+ switch item {
1387
+ | Internal.Event({payload, blockNumber}) as item =>
1388
+ switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1389
+ | Some(filter) =>
1390
+ filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1391
+ | None => true
1392
+ }
1393
+ | _ => true
1394
+ }
1395
+ )
1396
+
1290
1397
  /*
1291
1398
  Updates fetchState with a response for a given query.
1292
1399
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1295,26 +1402,10 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1295
1402
  */
1296
1403
  let handleQueryResult = (
1297
1404
  fetchState: t,
1298
- ~indexingAddresses: IndexingAddresses.t,
1299
1405
  ~query: query,
1300
1406
  ~latestFetchedBlock: blockNumberAndTimestamp,
1301
1407
  ~newItems,
1302
1408
  ): t => {
1303
- // Drop events an address-param filter rejects. A merged partition may
1304
- // over-fetch a wildcard event whose indexed address param references an
1305
- // address registered after the log's block; `clientAddressFilter` is the
1306
- // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1307
- let newItems = newItems->Array.filter(item =>
1308
- switch item {
1309
- | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1310
- switch onEventRegistration.clientAddressFilter {
1311
- | Some(filter) =>
1312
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1313
- | None => true
1314
- }
1315
- | _ => true
1316
- }
1317
- )
1318
1409
  fetchState->updateInternal(
1319
1410
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1320
1411
  ~query,
@@ -1322,10 +1413,14 @@ let handleQueryResult = (
1322
1413
  ~itemsCount=newItems->Array.length,
1323
1414
  ~latestFetchedBlock,
1324
1415
  ),
1416
+ // Merge the response into the sorted buffer, dropping duplicates an
1417
+ // overlapping query may re-deliver (e.g. an over-fetched log matched by two
1418
+ // partitions). Absorbs sorting too, so updateInternal doesn't re-sort.
1419
+ ~mutItemsSorted=true,
1325
1420
  ~mutItems=?{
1326
1421
  switch newItems {
1327
1422
  | [] => None
1328
- | _ => Some(fetchState.buffer->Array.concat(newItems))
1423
+ | _ => Some(fetchState.buffer->mergeIntoBuffer(newItems))
1329
1424
  }
1330
1425
  },
1331
1426
  )
@@ -1370,14 +1465,14 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1370
1465
  // Most parallel in-flight chunk queries a single partition may have at once.
1371
1466
  let maxPendingChunksPerPartition = 12
1372
1467
 
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.
1468
+ // Generates candidate queries for a gap range (a hole left between
1469
+ // completed/pending chunks, e.g. from an out-of-order partial response). Gaps
1470
+ // carry the range's low fromBlock, so the acceptance pass takes them before
1471
+ // forward progress. Priced by the partition's trusted density when it has one;
1472
+ // otherwise by the "available density" — the partition's equal-divide budget
1473
+ // spread over its remaining range this tick — so a small gap reserves
1474
+ // proportionally little instead of a noisy one-sample estimate. Chunks only on
1475
+ // a trusted POSITIVE density. Returns the created queries' total itemsEst.
1381
1476
  let pushGapFillQueries = (
1382
1477
  queries: array<query>,
1383
1478
  ~partitionId: string,
@@ -1481,46 +1576,19 @@ let pushGapFillQueries = (
1481
1576
  cost.contents
1482
1577
  }
1483
1578
 
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 = {
1579
+ // Per-partition state carried from the gap-fill/cursor walk to candidate
1580
+ // generation.
1581
+ type partitionFillState = {
1511
1582
  partitionId: string,
1512
1583
  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.
1584
+ cursor: int,
1585
+ // Chunks already generated for this partition during gap-fill — used with
1586
+ // pendingCount against maxPendingChunksPerPartition.
1587
+ chunksUsedThisCall: int,
1588
+ // Existing mutPendingQueries count before this call — fixed for the call.
1517
1589
  pendingCount: int,
1518
1590
  queryEndBlock: option<int>,
1519
1591
  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
1592
  }
1525
1593
 
1526
1594
  // Candidate queries are sized against chainTargetBlock, the soft querying
@@ -1531,24 +1599,25 @@ type waterFillState = {
1531
1599
  // query with no other ceiling: the true hard bounds stay
1532
1600
  // endBlock/mergeBlock/the lagged head.
1533
1601
  //
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.
1602
+ // The tick's budget is chainTargetItems minus what's already in flight. A
1603
+ // non-positive budget only resolves the wait action and generates no query
1604
+ // candidates. With a positive budget, every candidate query gap-fill holes,
1605
+ // plus each in-range partition's chunks or open-ended probe toward the target —
1606
+ // is generated with no budget check, then the candidates are sorted by
1607
+ // fromBlock and accepted in that order while the budget stays positive. The
1608
+ // query that tips it negative is still accepted (a single overshoot);
1609
+ // everything after it waits for a tick with more budget.
1610
+ // Sorting by fromBlock spends the budget on the earliest blocks across all
1611
+ // partitions first, so no partition is starved by generation order and the
1612
+ // frontier advances evenly. In-flight reservations release as responses land,
1613
+ // so acceptance redistributes across ticks.
1543
1614
  //
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.
1615
+ // A partition with source-capacity history and a positive density generates
1616
+ // density-sized chunks toward the target. Any other partition (no signal, no
1617
+ // capacity history, or a density-0 estimate) generates one open-ended probe
1618
+ // sized to the events its range to the target is expected to hold
1619
+ // rangeTargetDensity × (chainTargetBlock fromBlock + 1) / inRangeCount — so
1620
+ // unknown-density partitions probe in parallel within one budget.
1552
1621
  let getNextQuery = (
1553
1622
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1554
1623
  ~chainTargetBlock: int,
@@ -1588,8 +1657,14 @@ let getNextQuery = (
1588
1657
  }
1589
1658
  }
1590
1659
 
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
1660
+ // A zero budget is an admission check: preserve the wait-state scan above,
1661
+ // but make every query-generation pass below empty. Caught-up chains also
1662
+ // skip those passes because their action is already known.
1663
+ let partitionsCount =
1664
+ chainTargetItems <= 0. || shouldWaitForNewBlock.contents ? 0 : partitionsCount
1665
+
1666
+ // One bucket per partition, in idsInAscOrder order — gap-fill and the
1667
+ // budget pass both push into a partition's own bucket, so flattening at
1593
1668
  // the end (see below) reproduces idsInAscOrder without a sort.
1594
1669
  let queriesByPartitionIndex: array<
1595
1670
  array<query>,
@@ -1607,36 +1682,50 @@ let getNextQuery = (
1607
1682
  }
1608
1683
  }
1609
1684
 
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()
1685
+ // In-flight itemsEst summed over queries still being fetched
1686
+ // (fetchedBlock === None). A query whose response already landed has had its
1687
+ // reservation released by ChainState even while it lingers in
1688
+ // mutPendingQueries behind an unfilled gap, so counting it here would
1689
+ // understate the budget. Sizes fresh forward work below.
1616
1690
  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
1623
- }
1624
- existingReservedByPartition->Dict.set(partitionId, cost.contents)
1625
- chainReserved := chainReserved.contents +. cost.contents
1626
- }
1627
1691
 
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
1692
+ // (fromBlock, itemsEst) of each still-in-flight query. The acceptance pass
1693
+ // merges these into the candidate stream and draws them down in fromBlock
1694
+ // order, so a gap-fill sitting before an in-flight query claims budget ahead
1695
+ // of it and the buffer unblocks without waiting for that query to return.
1696
+ let reservations = []
1697
+
1698
+ // Position of each partition in idsInAscOrder, so an accepted query routes
1699
+ // back to its bucket and the output stays in idsInAscOrder. Filled in the
1700
+ // Phase A sweep below.
1701
+ let partitionIndexById = Dict.make()
1702
+
1703
+ // Every candidate query for this tick — gap-fill holes (Phase A) plus each
1704
+ // in-range partition's chunks/probe toward the target (Phase B) — generated
1705
+ // with no budget check, then merged with the in-flight reservations, sorted
1706
+ // by fromBlock, and accepted while the budget lasts (acceptance pass).
1707
+ // Selecting by fromBlock spends the budget on the earliest blocks across all
1708
+ // partitions first, so no partition is starved by iteration order and the
1709
+ // frontier advances evenly.
1710
+ let candidates = []
1711
+
1712
+ // Phase A: gap-fill. Walk each partition's pending queries once, generating
1713
+ // a candidate for any hole (e.g. from an out-of-order partial chunk
1714
+ // response). This also determines each partition's post-gap cursor and
1632
1715
  // whether it's blocked on an unresolved single-shot query.
1633
1716
  let fillStates = []
1634
- let gapFillCost = ref(0.)
1635
1717
  for idx in 0 to partitionsCount - 1 {
1636
1718
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1637
1719
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1638
- let bucket = queriesByPartitionIndex->Array.getUnsafe(idx)
1720
+ partitionIndexById->Dict.set(partitionId, idx)
1639
1721
  let pendingCount = p.mutPendingQueries->Array.length
1722
+ for pqIdx in 0 to pendingCount - 1 {
1723
+ let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx)
1724
+ if pq.fetchedBlock === None {
1725
+ chainReserved := chainReserved.contents +. pq.itemsEst->Int.toFloat
1726
+ reservations->Array.push((pq.fromBlock, pq.itemsEst))->ignore
1727
+ }
1728
+ }
1640
1729
  let queryEndBlock = computeQueryEndBlock(p)
1641
1730
  let maybeChunkRange = getMinHistoryRange(p)
1642
1731
 
@@ -1648,9 +1737,9 @@ let getNextQuery = (
1648
1737
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1649
1738
 
1650
1739
  if pq.fromBlock > cursor.contents {
1651
- let beforeLen = bucket->Array.length
1652
- let cost = pushGapFillQueries(
1653
- bucket,
1740
+ let beforeLen = candidates->Array.length
1741
+ pushGapFillQueries(
1742
+ candidates,
1654
1743
  ~partitionId,
1655
1744
  ~rangeFromBlock=cursor.contents,
1656
1745
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
@@ -1663,9 +1752,8 @@ let getNextQuery = (
1663
1752
  ~chunkItemsMultiplier,
1664
1753
  ~selection=p.selection,
1665
1754
  ~addressesByContractName=p.addressesByContractName,
1666
- )
1667
- chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1668
- gapFillCost := gapFillCost.contents +. cost
1755
+ )->ignore
1756
+ chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
1669
1757
  }
1670
1758
  switch pq {
1671
1759
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1687,35 +1775,17 @@ let getNextQuery = (
1687
1775
  pendingCount,
1688
1776
  queryEndBlock,
1689
1777
  maybeChunkRange,
1690
- bucket,
1691
1778
  })
1692
1779
  ->ignore
1693
1780
  }
1694
1781
  }
1695
1782
 
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
- )
1783
+ // Budget for fresh forward work: chainTargetItems minus what's still in
1784
+ // flight. Sizes probes and bounds chunk generation below; the acceptance
1785
+ // pass does the final budgeting against the full chainTargetItems.
1786
+ let freshBudget = Pervasives.max(0., chainTargetItems -. chainReserved.contents)
1704
1787
 
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
1714
- }
1715
- reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1716
- })
1717
-
1718
- let isInRange = (fs: waterFillState) =>
1788
+ let isInRange = (fs: partitionFillState) =>
1719
1789
  fs.cursor <= chainTargetBlock &&
1720
1790
  switch fs.queryEndBlock {
1721
1791
  | Some(eb) => fs.cursor <= eb
@@ -1724,16 +1794,32 @@ let getNextQuery = (
1724
1794
  fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
1725
1795
 
1726
1796
  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.
1797
+ let inRangeCount = inRangeStates->Array.length
1798
+ // Even share of the fresh budget across the partitions actually fetching
1799
+ // this tick (not every partition — so budget isn't stranded on ones below
1800
+ // the head, waiting, or already done). The fallback when there's no range to
1801
+ // the target.
1802
+ let probeShare = inRangeCount == 0 ? 0. : freshBudget /. inRangeCount->Int.toFloat
1803
+ // Items/block the budget implies over the range those partitions cover this
1804
+ // tick — from the furthest-behind in-range cursor to the target. A probe
1805
+ // covering less of that range (its partition sits further ahead) gets
1806
+ // proportionally fewer items; one starting at the frontier gets the full
1807
+ // even share.
1808
+ let frontierCursor =
1809
+ inRangeStates->Array.reduce(chainTargetBlock, (min, fs) => fs.cursor < min ? fs.cursor : min)
1810
+ let rangeToTarget = chainTargetBlock - frontierCursor + 1
1811
+ let rangeTargetDensity =
1812
+ inRangeCount > 0 && rangeToTarget > 0 ? freshBudget /. rangeToTarget->Int.toFloat : 0.
1813
+
1814
+ // Phase B: generate each in-range partition's candidates — strict chunks
1815
+ // when both source-capacity history and density are known, or an open-ended
1816
+ // budget probe otherwise. No budget check here; the acceptance pass below
1817
+ // decides which candidates make the cut.
1731
1818
  //
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) => {
1819
+ // Chunks require a POSITIVE trusted density: density 0 prices every chunk at
1820
+ // ~nothing, so an open-ended probe (full server scan range in one response)
1821
+ // beats a pipeline of hard-bounded chunks that crawl 1.8× per two responses.
1822
+ inRangeStates->Array.forEach(fs => {
1737
1823
  let p = fs.p
1738
1824
  let maxBlock = switch fs.queryEndBlock {
1739
1825
  | Some(eb) => eb
@@ -1741,23 +1827,25 @@ let getNextQuery = (
1741
1827
  }
1742
1828
  switch (fs.maybeChunkRange, p->getTrustedDensity) {
1743
1829
  | (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
1830
  let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1748
1831
  let maxChunksRemaining =
1749
1832
  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
1833
  // No chunk starts past chainTargetBlock; an emitted chunk still keeps
1755
1834
  // its full span (chunkToBlock may exceed the target — only
1756
1835
  // endBlock/mergeBlock are hard bounds).
1836
+ let chunkStartCeiling = Pervasives.min(maxBlock, chainTargetBlock)
1837
+ let created = ref(0)
1838
+ let chunkFromBlock = ref(fs.cursor)
1839
+ // Stop once this partition alone has generated more than the whole fresh
1840
+ // budget: the acceptance pass can hand a single partition at most the
1841
+ // budget plus one overshoot query, so further chunks could never be
1842
+ // accepted. Bounds generation (and the candidate sort) when the budget
1843
+ // is small relative to the pending-chunk cap.
1844
+ let generatedItems = ref(0.)
1757
1845
  while (
1758
- budgetLeft.contents &&
1759
1846
  created.contents < maxChunksRemaining &&
1760
- chunkFromBlock.contents <= Pervasives.min(maxBlock, chainTargetBlock)
1847
+ chunkFromBlock.contents <= chunkStartCeiling &&
1848
+ generatedItems.contents <= freshBudget
1761
1849
  ) {
1762
1850
  let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1763
1851
  let rawEst = density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat
@@ -1766,89 +1854,103 @@ let getNextQuery = (
1766
1854
  1,
1767
1855
  (rawEst *. chunkItemsMultiplier)->Math.ceil->Float.toInt,
1768
1856
  )
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
- }
1857
+ candidates
1858
+ ->Array.push({
1859
+ partitionId: fs.partitionId,
1860
+ fromBlock: chunkFromBlock.contents,
1861
+ toBlock: Some(chunkToBlock),
1862
+ isChunk: true,
1863
+ selection: p.selection,
1864
+ itemsTarget,
1865
+ itemsEst,
1866
+ addressesByContractName: p.addressesByContractName,
1867
+ })
1868
+ ->ignore
1869
+ generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
1870
+ chunkFromBlock := chunkToBlock + 1
1871
+ created := created.contents + 1
1786
1872
  }
1787
- fs.cursor = chunkFromBlock.contents
1788
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + created.contents
1789
- consumed.contents
1790
1873
  | _ =>
1791
- let itemsTarget = Pervasives.max(1, Math.round(budget)->Float.toInt)
1792
- let itemsEst = itemsTarget
1793
- fs.bucket->Array.push({
1874
+ // Size the probe by the events its range to the target is expected to
1875
+ // hold rangeTargetDensity × (chainTargetBlock − fromBlock + 1), split
1876
+ // across the partitions fetching this tick. With no range to the target
1877
+ // fall back to an even share of the fresh budget, so cold chains and
1878
+ // caught-up partitions still probe.
1879
+ let itemsTarget = if rangeToTarget > 0 {
1880
+ Pervasives.max(
1881
+ 1,
1882
+ Math.round(
1883
+ rangeTargetDensity *.
1884
+ (chainTargetBlock - fs.cursor + 1)->Int.toFloat /.
1885
+ inRangeCount->Int.toFloat,
1886
+ )->Float.toInt,
1887
+ )
1888
+ } else {
1889
+ Pervasives.max(1, Math.round(probeShare)->Float.toInt)
1890
+ }
1891
+ candidates
1892
+ ->Array.push({
1794
1893
  partitionId: fs.partitionId,
1795
1894
  fromBlock: fs.cursor,
1796
1895
  toBlock: fs.queryEndBlock,
1797
1896
  isChunk: false,
1798
1897
  selection: p.selection,
1799
1898
  itemsTarget,
1800
- itemsEst,
1899
+ itemsEst: itemsTarget,
1801
1900
  addressesByContractName: p.addressesByContractName,
1802
1901
  })
1803
- fs.cursor = maxBlock + 1
1804
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + 1
1805
- itemsEst->Int.toFloat
1902
+ ->ignore
1806
1903
  }
1807
- }
1904
+ })
1808
1905
 
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
- }
1906
+ // Acceptance: merge fresh candidates (Some) with the in-flight reservations
1907
+ // (None) and walk them in fromBlock order, starting from the full
1908
+ // chainTargetItems. A reservation just draws down the budget its query is
1909
+ // already sent while a candidate draws down the budget and is emitted.
1910
+ // Because a gap-fill's fromBlock precedes the in-flight query it unblocks,
1911
+ // it claims budget ahead of that reservation, so the buffer never deadlocks
1912
+ // waiting on a hole it can't fund. The candidate that tips the budget
1913
+ // negative is still emitted (a single overshoot); everything after it waits
1914
+ // for a tick with more budget. Accepted queries route back to their
1915
+ // partition bucket, so the output stays in idsInAscOrder with each
1916
+ // partition's queries in fromBlock order.
1917
+ let acceptanceStream = []
1918
+ candidates->Array.forEach(query =>
1919
+ acceptanceStream->Array.push((query.fromBlock, query.itemsEst, Some(query)))->ignore
1920
+ )
1921
+ reservations->Array.forEach(((fromBlock, itemsEst)) =>
1922
+ acceptanceStream->Array.push((fromBlock, itemsEst, None))->ignore
1923
+ )
1924
+ // Sort by fromBlock; on a tie charge the in-flight reservation (None) before
1925
+ // a fresh candidate (Some), so a same-block candidate can't overshoot the
1926
+ // target buffer. Only a strictly-earlier candidate — a gap-fill, whose
1927
+ // fromBlock precedes the query it unblocks — borrows ahead of a reservation.
1928
+ acceptanceStream->Array.sort(((aFrom, _, aQuery), (bFrom, _, bQuery)) =>
1929
+ if aFrom !== bFrom {
1930
+ Int.compare(aFrom, bFrom)
1931
+ } else {
1932
+ switch (aQuery, bQuery) {
1933
+ | (None, Some(_)) => Ordering.less
1934
+ | (Some(_), None) => Ordering.greater
1935
+ | (None, None) | (Some(_), Some(_)) => Ordering.equal
1843
1936
  }
1844
- })
1845
- notFilledPartitions := next
1937
+ }
1938
+ )
1939
+ let streamCount = acceptanceStream->Array.length
1940
+ let remainingBudget = ref(chainTargetItems)
1941
+ let acceptIdx = ref(0)
1942
+ while remainingBudget.contents > 0. && acceptIdx.contents < streamCount {
1943
+ let (_, itemsEst, maybeQuery) = acceptanceStream->Array.getUnsafe(acceptIdx.contents)
1944
+ switch maybeQuery {
1945
+ | Some(query) =>
1946
+ let partitionIdx = partitionIndexById->Dict.getUnsafe(query.partitionId)
1947
+ queriesByPartitionIndex->Array.getUnsafe(partitionIdx)->Array.push(query)->ignore
1948
+ | None => ()
1949
+ }
1950
+ remainingBudget := remainingBudget.contents -. itemsEst->Int.toFloat
1951
+ acceptIdx := acceptIdx.contents + 1
1846
1952
  }
1847
1953
 
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
1954
  let queries = queriesByPartitionIndex->Array.flat
1853
1955
 
1854
1956
  if queries->Utils.Array.isEmpty {
@@ -1943,10 +2045,10 @@ let make = (
1943
2045
  mergeBlock: None,
1944
2046
  dynamicContract: None,
1945
2047
  mutPendingQueries: [],
1946
- prevQueryRange: 0,
1947
- prevPrevQueryRange: 0,
1948
- prevRangeSize: 0,
1949
- latestBlockRangeUpdateBlock: 0,
2048
+ sourceRangeCapacity: 0,
2049
+ prevSourceRangeCapacity: 0,
2050
+ eventDensity: None,
2051
+ latestSourceRangeCapacityUpdateBlock: 0,
1950
2052
  })
1951
2053
  }
1952
2054
 
@@ -2189,6 +2291,8 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2189
2291
  ),
2190
2292
  }->updateInternal(
2191
2293
  ~optimizedPartitions,
2294
+ // Filtering the sorted buffer keeps it sorted and deduped.
2295
+ ~mutItemsSorted=true,
2192
2296
  ~mutItems=fetchState.buffer->Array.filter(item =>
2193
2297
  switch item {
2194
2298
  | Event({blockNumber})
@@ -2264,13 +2368,15 @@ let isReadyToEnterReorgThreshold = ({endBlock, blockLag, buffer, knownHeight} as
2264
2368
  buffer->Utils.Array.isEmpty
2265
2369
  }
2266
2370
 
2267
- // Lower progress percentage = further behind = higher priority. Shared by the
2268
- // batch ordering and the cross-chain fetch priority.
2371
+ // Lower progress percentage = further behind = higher priority. Progress is
2372
+ // relative to the head this chain can actually fetch, so a chain at its lagged
2373
+ // head does not look behind relative to unavailable blocks. Shared by the batch
2374
+ // ordering and the cross-chain fetch priority.
2269
2375
  let getProgressPercentage = (fetchState: t) => {
2270
2376
  switch fetchState.firstEventBlock {
2271
2377
  | None => 0.
2272
2378
  | Some(firstEventBlock) =>
2273
- let totalRange = fetchState.knownHeight - firstEventBlock
2379
+ let totalRange = fetchState.knownHeight - fetchState.blockLag - firstEventBlock
2274
2380
  if totalRange <= 0 {
2275
2381
  0.
2276
2382
  } else {