envio 3.3.0-alpha.9 → 3.3.0

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 (94) hide show
  1. package/index.d.ts +19 -0
  2. package/licenses/CLA.md +35 -0
  3. package/licenses/EULA.md +67 -0
  4. package/licenses/LICENSE.md +45 -0
  5. package/licenses/README.md +35 -0
  6. package/package.json +9 -8
  7. package/src/Batch.res.mjs +1 -1
  8. package/src/BatchProcessing.res +0 -7
  9. package/src/BatchProcessing.res.mjs +1 -8
  10. package/src/ChainFetching.res +30 -18
  11. package/src/ChainFetching.res.mjs +23 -13
  12. package/src/ChainState.res +134 -55
  13. package/src/ChainState.res.mjs +78 -36
  14. package/src/ChainState.resi +14 -2
  15. package/src/Config.res +9 -5
  16. package/src/Config.res.mjs +2 -2
  17. package/src/Core.res +20 -0
  18. package/src/Core.res.mjs +12 -0
  19. package/src/CrossChainState.res +115 -34
  20. package/src/CrossChainState.res.mjs +37 -14
  21. package/src/EffectState.res +100 -0
  22. package/src/EffectState.res.mjs +74 -0
  23. package/src/EffectState.resi +32 -0
  24. package/src/Envio.res +25 -7
  25. package/src/Envio.res.mjs +15 -19
  26. package/src/EventConfigBuilder.res +21 -46
  27. package/src/EventConfigBuilder.res.mjs +18 -25
  28. package/src/EventProcessing.res +8 -5
  29. package/src/EventProcessing.res.mjs +2 -1
  30. package/src/FetchState.res +814 -467
  31. package/src/FetchState.res.mjs +541 -369
  32. package/src/HandlerRegister.res +3 -1
  33. package/src/HandlerRegister.res.mjs +3 -2
  34. package/src/InMemoryStore.res +14 -26
  35. package/src/InMemoryStore.res.mjs +6 -19
  36. package/src/IndexerState.res +15 -39
  37. package/src/IndexerState.res.mjs +18 -29
  38. package/src/IndexerState.resi +2 -10
  39. package/src/Internal.res +98 -14
  40. package/src/Internal.res.mjs +96 -2
  41. package/src/LoadLayer.res +44 -24
  42. package/src/LoadLayer.res.mjs +43 -20
  43. package/src/LoadLayer.resi +1 -0
  44. package/src/LogSelection.res +0 -62
  45. package/src/LogSelection.res.mjs +0 -74
  46. package/src/Metrics.res +1 -1
  47. package/src/Metrics.res.mjs +1 -1
  48. package/src/Persistence.res +12 -3
  49. package/src/PgStorage.res +155 -82
  50. package/src/PgStorage.res.mjs +130 -77
  51. package/src/Prometheus.res +20 -10
  52. package/src/Prometheus.res.mjs +22 -10
  53. package/src/PruneStaleHistory.res +146 -35
  54. package/src/PruneStaleHistory.res.mjs +114 -20
  55. package/src/RawEvent.res +2 -2
  56. package/src/Rollback.res +32 -13
  57. package/src/Rollback.res.mjs +24 -11
  58. package/src/SimulateDeadInputTracker.res +1 -1
  59. package/src/SimulateItems.res +38 -6
  60. package/src/SimulateItems.res.mjs +28 -9
  61. package/src/TestIndexer.res +2 -2
  62. package/src/TestIndexer.res.mjs +2 -2
  63. package/src/TopicFilter.res +1 -25
  64. package/src/TopicFilter.res.mjs +0 -74
  65. package/src/UserContext.res +62 -23
  66. package/src/UserContext.res.mjs +26 -6
  67. package/src/Writing.res +60 -21
  68. package/src/Writing.res.mjs +27 -9
  69. package/src/bindings/NodeJs.res +5 -0
  70. package/src/bindings/Viem.res +0 -5
  71. package/src/sources/EventRouter.res +0 -21
  72. package/src/sources/EventRouter.res.mjs +0 -12
  73. package/src/sources/EvmChain.res +2 -27
  74. package/src/sources/EvmChain.res.mjs +2 -23
  75. package/src/sources/EvmRpcClient.res +58 -23
  76. package/src/sources/EvmRpcClient.res.mjs +11 -5
  77. package/src/sources/HyperSync.res +9 -38
  78. package/src/sources/HyperSync.res.mjs +16 -28
  79. package/src/sources/HyperSync.resi +2 -2
  80. package/src/sources/HyperSyncClient.res +88 -11
  81. package/src/sources/HyperSyncClient.res.mjs +39 -6
  82. package/src/sources/HyperSyncSource.res +18 -199
  83. package/src/sources/HyperSyncSource.res.mjs +9 -128
  84. package/src/sources/Rpc.res +0 -32
  85. package/src/sources/Rpc.res.mjs +1 -46
  86. package/src/sources/RpcSource.res +129 -522
  87. package/src/sources/RpcSource.res.mjs +161 -366
  88. package/src/sources/SimulateSource.res +37 -19
  89. package/src/sources/SimulateSource.res.mjs +27 -10
  90. package/src/sources/SourceManager.res +3 -7
  91. package/src/sources/SourceManager.res.mjs +2 -7
  92. package/src/sources/SourceManager.resi +0 -2
  93. package/src/sources/SvmHyperSyncSource.res +13 -3
  94. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
@@ -16,10 +16,12 @@ type pendingQuery = {
16
16
  fromBlock: int,
17
17
  toBlock: option<int>,
18
18
  isChunk: bool,
19
- // Items this in-flight query is targeting (server maxNumLogs-style cap), carried
20
- // from the query so the shared buffer budget can account for what's already
21
- // being fetched.
19
+ // Items this in-flight query is targeting (server maxNumLogs-style cap).
22
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,
23
25
  // Stores latestFetchedBlock when query completes. Only needed to persist
24
26
  // timestamp while earlier queries are still pending before updating
25
27
  // the partition's latestFetchedBlock.
@@ -43,19 +45,26 @@ type partition = {
43
45
  // When set, partition indexes a single dynamic contract type.
44
46
  // The addressesByContractName must contain only addresses for this contract.
45
47
  dynamicContract: option<string>,
46
- // Mutable array for SourceManager sync - queries exist only while being fetched
48
+ // Mutated in place and shared across fetchState versions (updateInternal
49
+ // copies the record, not this array): startFetchingQueries inserts,
50
+ // handleQueryResponse marks fetched and consumes, and the same array object
51
+ // survives every copy. Invariant: any path that invalidates the fetch
52
+ // frontier (reorg/rollback) must go through resetPendingQueries, otherwise a
53
+ // stale version's in-flight bookkeeping leaks into the restored state.
47
54
  mutPendingQueries: array<pendingQuery>,
48
- // Track last 3 successful query ranges for chunking heuristic (0 means no data)
49
- prevQueryRange: int,
50
- prevPrevQueryRange: int,
51
- // Item count of the response that set prevQueryRange. Paired with it to derive
52
- // the partition's event density (prevRangeSize / prevQueryRange) for sizing
53
- // queries against the buffer budget.
54
- prevRangeSize: int,
55
+ // The last two ranges that measured how many blocks the source could return.
56
+ // Both must be non-zero before the minimum is trusted for chunk sizing.
57
+ sourceRangeCapacity: int,
58
+ prevSourceRangeCapacity: int,
59
+ // Smoothed items/block observed in responses. This is independent from the
60
+ // source's range capacity: even a response truncated by our own itemsTarget
61
+ // cap is useful density evidence while saying nothing about source capacity.
62
+ // None distinguishes a new partition from a real zero-density observation.
63
+ eventDensity: option<float>,
55
64
  // Tracks the latestFetchedBlock.blockNumber of the most recent response
56
- // that updated prevQueryRange. Prevents degradation of the chunking heuristic
57
- // when parallel query responses arrive out of order.
58
- latestBlockRangeUpdateBlock: int,
65
+ // that updated sourceRangeCapacity. Prevents degradation of the chunking
66
+ // heuristic when parallel query responses arrive out of order.
67
+ latestSourceRangeCapacityUpdateBlock: int,
59
68
  }
60
69
 
61
70
  type query = {
@@ -63,12 +72,15 @@ type query = {
63
72
  fromBlock: int,
64
73
  toBlock: option<int>,
65
74
  isChunk: bool,
66
- // Items this query targets: the server-side maxNumLogs-style cap, and the
67
- // unit the chain's per-tick budget is reserved/consumed in. For a partition
68
- // with known density this is density × the query's block range; for a
69
- // partition with no signal yet it's whatever budget share the query was
70
- // sized against.
75
+ // Items this query targets: the server-side maxNumLogs-style cap, sized
76
+ // with headroom (chunkItemsMultiplier) so a denser-than-expected range
77
+ // doesn't truncate the response.
71
78
  itemsTarget: int,
79
+ // Expected items without headroom: density × the query's block range for a
80
+ // known-density partition, the query's budget share otherwise. This is the
81
+ // unit the chain's per-tick budget is reserved/consumed in — reserving the
82
+ // headroomed cap instead would throttle the pipeline by the safety margin.
83
+ itemsEst: int,
72
84
  selection: selection,
73
85
  addressesByContractName: dict<array<Address.t>>,
74
86
  }
@@ -106,22 +118,17 @@ let densityItemsTarget = (~density, ~fromBlock, ~toBlock, ~chainTargetBlock) =>
106
118
  )
107
119
  }
108
120
 
109
- // Calculate the chunk range from history using min-of-last-3-ranges heuristic
121
+ // Calculate the chunk range from the last two source-capacity observations.
110
122
  let getMinHistoryRange = (p: partition) => {
111
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
123
+ switch (p.sourceRangeCapacity, p.prevSourceRangeCapacity) {
112
124
  | (0, _) | (_, 0) => None
113
125
  | (a, b) => Some(a < b ? a : b)
114
126
  }
115
127
  }
116
128
 
117
- // Density (items/block) from the last response, trusted only after two
118
- // responses a single sample is too noisy to size the next query by.
119
- let getTrustedDensity = (p: partition) => {
120
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
121
- | (0, _) | (_, 0) => None
122
- | (prevQueryRange, _) => Some(p.prevRangeSize->Int.toFloat /. prevQueryRange->Int.toFloat)
123
- }
124
- }
129
+ // Density has its own initialization signal and is useful independently from
130
+ // source-capacity history, including when an itemsTarget cap truncates a response.
131
+ let getTrustedDensity = (p: partition) => p.eventDensity
125
132
 
126
133
  let getMinQueryRange = (partitions: array<partition>) => {
127
134
  let min = ref(0)
@@ -129,8 +136,8 @@ let getMinQueryRange = (partitions: array<partition>) => {
129
136
  let p = partitions->Array.getUnsafe(i)
130
137
 
131
138
  // Even if it's fetching, set dynamicContract field
132
- let a = p.prevQueryRange
133
- let b = p.prevPrevQueryRange
139
+ let a = p.sourceRangeCapacity
140
+ let b = p.prevSourceRangeCapacity
134
141
  if a > 0 && (min.contents == 0 || a < min.contents) {
135
142
  min := a
136
143
  }
@@ -203,11 +210,14 @@ module OptimizedPartitions = {
203
210
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
204
211
  let minRange = getMinQueryRange([p1, p2])
205
212
  // The merged partition indexes both parents' addresses, so its expected
206
- // event rate is the sum of their densities. Parents without a trusted
207
- // density contribute 0; if none has one, prevRangeSize stays 0 and the
208
- // partition probes for a fresh signal instead of chunking.
209
- let inheritedDensity =
210
- p1->getTrustedDensity->Option.getOr(0.) +. p2->getTrustedDensity->Option.getOr(0.)
213
+ // event rate is the sum of their densities. If neither parent has a
214
+ // trusted density, the merged partition probes for a fresh signal
215
+ // instead of treating zero as an observed density.
216
+ let inheritedDensity = switch (p1->getTrustedDensity, p2->getTrustedDensity) {
217
+ | (Some(p1Density), Some(p2Density)) => Some(p1Density +. p2Density)
218
+ | (Some(density), None) | (None, Some(density)) => Some(density)
219
+ | (None, None) => None
220
+ }
211
221
  {
212
222
  id: newId,
213
223
  dynamicContract: Some(contractName),
@@ -216,10 +226,10 @@ module OptimizedPartitions = {
216
226
  mergeBlock: None,
217
227
  addressesByContractName: Dict.make(), // set below
218
228
  mutPendingQueries: [],
219
- prevQueryRange: minRange,
220
- prevPrevQueryRange: minRange,
221
- prevRangeSize: (inheritedDensity *. minRange->Int.toFloat)->Math.ceil->Float.toInt,
222
- latestBlockRangeUpdateBlock: 0,
229
+ sourceRangeCapacity: minRange,
230
+ prevSourceRangeCapacity: minRange,
231
+ eventDensity: inheritedDensity,
232
+ latestSourceRangeCapacityUpdateBlock: 0,
223
233
  }
224
234
  }
225
235
 
@@ -420,14 +430,19 @@ module OptimizedPartitions = {
420
430
  ) => {
421
431
  let latestFetchedBlock = ref(initialLatestFetchedBlock)
422
432
 
423
- while (
424
- mutPendingQueries->Array.length > 0 && {
425
- let pq = mutPendingQueries->Utils.Array.firstUnsafe
426
- pq.fetchedBlock !== None && pq.fromBlock <= latestFetchedBlock.contents.blockNumber + 1
427
- }
428
- ) {
429
- let removedQuery = mutPendingQueries->Array.shift->Option.getUnsafe
430
- latestFetchedBlock := removedQuery.fetchedBlock->Option.getUnsafe
433
+ let consumedCount = ref(0)
434
+ let canContinue = ref(true)
435
+ while canContinue.contents {
436
+ switch mutPendingQueries->Array.get(consumedCount.contents) {
437
+ | Some({fetchedBlock: Some(fetchedBlock), fromBlock})
438
+ if fromBlock <= latestFetchedBlock.contents.blockNumber + 1 =>
439
+ latestFetchedBlock := fetchedBlock
440
+ consumedCount := consumedCount.contents + 1
441
+ | _ => canContinue := false
442
+ }
443
+ }
444
+ if consumedCount.contents > 0 {
445
+ mutPendingQueries->Array.splice(~start=0, ~remove=consumedCount.contents, ~insert=[])->ignore
431
446
  }
432
447
 
433
448
  latestFetchedBlock.contents
@@ -467,28 +482,51 @@ module OptimizedPartitions = {
467
482
  pendingQuery.fetchedBlock = Some(latestFetchedBlock)
468
483
 
469
484
  let blockRange = latestFetchedBlock.blockNumber - query.fromBlock + 1
470
- // Skip updating block range if a later response already updated it.
485
+ // Update density for every response, independently from whether this range
486
+ // is valid evidence of source capacity. A cap hit is still useful density
487
+ // evidence because it reports items returned across the scanned range.
488
+ let observedEventDensity = itemsCount->Int.toFloat /. blockRange->Int.toFloat
489
+ // Seed from the first observation, then smooth every later observation
490
+ // with a 1:1 moving average. Keeping initialization explicit is important:
491
+ // zero is valid density evidence and must participate in the next blend.
492
+ let updatedEventDensity = switch p.eventDensity {
493
+ | None => Some(observedEventDensity)
494
+ | Some(eventDensity) => Some((eventDensity +. observedEventDensity) /. 2.)
495
+ }
496
+
497
+ // Skip updating source capacity if a later response already updated it.
471
498
  // Prevents degradation of the chunking heuristic when parallel query
472
499
  // responses arrive out of order (e.g. earlier query with smaller range
473
500
  // arriving after a later query with bigger range).
474
- let shouldUpdateBlockRange =
475
- latestFetchedBlock.blockNumber > p.latestBlockRangeUpdateBlock &&
501
+ let shouldUpdateSourceRangeCapacity =
502
+ latestFetchedBlock.blockNumber > p.latestSourceRangeCapacityUpdateBlock &&
476
503
  switch query.toBlock {
477
- | None => latestFetchedBlock.blockNumber < knownHeight - 10 // Don't update block range when very close to the head
504
+ | None =>
505
+ // Don't update source capacity when very close to the head.
506
+ latestFetchedBlock.blockNumber < knownHeight - 10
478
507
  | Some(queryToBlock) =>
479
- // Update on partial response (direct capacity evidence),
480
- // or when the query's intended range covers at least the partition's
481
- // current chunk range meaning it was a capacity-based split chunk,
482
- // not a small gap-fill whose toBlock is an artificial boundary.
483
- latestFetchedBlock.blockNumber < queryToBlock ||
508
+ if latestFetchedBlock.blockNumber < queryToBlock {
509
+ // Partial response is direct capacity evidence unless it was
510
+ // truncated by our own itemsTarget cap: that reflects the
511
+ // reservation we asked for, not what the server could return.
512
+ itemsCount < query.itemsTarget
513
+ } else {
514
+ // A full response updates only when the query's intended range
515
+ // covers at least the partition's current chunk range — meaning it
516
+ // was a capacity-based split chunk, not a small gap-fill whose
517
+ // toBlock is an artificial boundary.
484
518
  switch getMinHistoryRange(p) {
485
519
  | None => false // Chunking not active yet, don't update
486
520
  | Some(minHistoryRange) => queryToBlock - query.fromBlock + 1 >= minHistoryRange
487
521
  }
522
+ }
488
523
  }
489
- let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
490
- let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
491
- let updatedPrevRangeSize = shouldUpdateBlockRange ? itemsCount : p.prevRangeSize
524
+ let updatedSourceRangeCapacity = shouldUpdateSourceRangeCapacity
525
+ ? blockRange
526
+ : p.sourceRangeCapacity
527
+ let updatedPrevSourceRangeCapacity = shouldUpdateSourceRangeCapacity
528
+ ? p.sourceRangeCapacity
529
+ : p.prevSourceRangeCapacity
492
530
 
493
531
  // Process fetched queries from front of queue for main partition
494
532
  let updatedLatestFetchedBlock = consumeFetchedQueries(
@@ -504,28 +542,65 @@ module OptimizedPartitions = {
504
542
 
505
543
  if partitionReachedMergeBlock {
506
544
  mutEntities->Utils.Dict.deleteInPlace(p.id)
545
+
546
+ // Re-optimize to maintain sorted order and apply optimizations
547
+ make(
548
+ ~partitions=mutEntities->Dict.valuesToArray,
549
+ ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
550
+ ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
551
+ ~dynamicContracts=optimizedPartitions.dynamicContracts,
552
+ )
507
553
  } else {
508
554
  let updatedMainPartition = {
509
555
  ...p,
510
556
  latestFetchedBlock: updatedLatestFetchedBlock,
511
- prevQueryRange: updatedPrevQueryRange,
512
- prevPrevQueryRange: updatedPrevPrevQueryRange,
513
- prevRangeSize: updatedPrevRangeSize,
514
- latestBlockRangeUpdateBlock: shouldUpdateBlockRange
557
+ sourceRangeCapacity: updatedSourceRangeCapacity,
558
+ prevSourceRangeCapacity: updatedPrevSourceRangeCapacity,
559
+ eventDensity: updatedEventDensity,
560
+ latestSourceRangeCapacityUpdateBlock: shouldUpdateSourceRangeCapacity
515
561
  ? latestFetchedBlock.blockNumber
516
- : p.latestBlockRangeUpdateBlock,
562
+ : p.latestSourceRangeCapacityUpdateBlock,
517
563
  }
518
564
 
519
565
  mutEntities->Dict.set(p.id, updatedMainPartition)
520
- }
521
566
 
522
- // Re-optimize to maintain sorted order and apply optimizations
523
- make(
524
- ~partitions=mutEntities->Dict.valuesToArray,
525
- ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
526
- ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
527
- ~dynamicContracts=optimizedPartitions.dynamicContracts,
528
- )
567
+ if optimizedPartitions.dynamicContracts->Utils.Set.size === 0 {
568
+ // Fast path: merging only ever applies to dynamic-contract partitions,
569
+ // so with none registered a full make() would just re-sort. The updated
570
+ // partition's frontier only advances (consumeFetchedQueries never moves
571
+ // it back), so restoring idsInAscOrder is a single rightward walk of
572
+ // its id — and usually a no-op.
573
+ let ids = optimizedPartitions.idsInAscOrder
574
+ let count = ids->Array.length
575
+ let idx = ids->Array.indexOf(p.id)
576
+ let isAfter = jdx =>
577
+ jdx < count &&
578
+ (
579
+ mutEntities->Dict.getUnsafe(ids->Array.getUnsafe(jdx))
580
+ ).latestFetchedBlock.blockNumber <
581
+ updatedMainPartition.latestFetchedBlock.blockNumber
582
+ if isAfter(idx + 1) {
583
+ let reordered = ids->Array.copy
584
+ let jdx = ref(idx)
585
+ while isAfter(jdx.contents + 1) {
586
+ reordered->Array.setUnsafe(jdx.contents, ids->Array.getUnsafe(jdx.contents + 1))
587
+ jdx := jdx.contents + 1
588
+ }
589
+ reordered->Array.setUnsafe(jdx.contents, p.id)
590
+ {...optimizedPartitions, entities: mutEntities, idsInAscOrder: reordered}
591
+ } else {
592
+ {...optimizedPartitions, entities: mutEntities}
593
+ }
594
+ } else {
595
+ // Re-optimize to check for merge opportunities and maintain sorted order
596
+ make(
597
+ ~partitions=mutEntities->Dict.valuesToArray,
598
+ ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
599
+ ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
600
+ ~dynamicContracts=optimizedPartitions.dynamicContracts,
601
+ )
602
+ }
603
+ }
529
604
  }
530
605
 
531
606
  @inline
@@ -618,13 +693,90 @@ let bufferReadyCount = (fetchState: t) => {
618
693
  /*
619
694
  Comparitor for two events from the same chain. No need for chain id or timestamp
620
695
  */
621
- let compareBufferItem = (a: Internal.item, b: Internal.item) => {
622
- let blockOrdering = Int.compare(a->Internal.getItemBlockNumber, b->Internal.getItemBlockNumber)
623
- if blockOrdering === Ordering.equal {
624
- Int.compare(a->Internal.getItemLogIndex, b->Internal.getItemLogIndex)
696
+ let getRegistrationIndex = (item: Internal.item): int =>
697
+ switch item {
698
+ | Event({onEventRegistration}) => onEventRegistration.index
699
+ | Block({onBlockRegistration}) => onBlockRegistration.index
700
+ }
701
+
702
+ // Total order on buffer items: block, then logIndex, then registration index.
703
+ // Returns a plain int (-1/0/1) with explicit field comparisons so it can be
704
+ // called directly from the merge/insertion loops below — no Array.sort callback,
705
+ // no allocated key. `0` means a true duplicate: same log routed to the same
706
+ // registration (two registrations for one log differ by index and are kept).
707
+ let compareBufferItem = (a: Internal.item, b: Internal.item): int => {
708
+ let ba = a->Internal.getItemBlockNumber
709
+ let bb = b->Internal.getItemBlockNumber
710
+ if ba != bb {
711
+ ba < bb ? -1 : 1
625
712
  } else {
626
- blockOrdering
713
+ let la = a->Internal.getItemLogIndex
714
+ let lb = b->Internal.getItemLogIndex
715
+ if la != lb {
716
+ la < lb ? -1 : 1
717
+ } else {
718
+ let ia = a->getRegistrationIndex
719
+ let ib = b->getRegistrationIndex
720
+ ia < ib ? -1 : ia > ib ? 1 : 0
721
+ }
722
+ }
723
+ }
724
+
725
+ // Merge a maybe-unsorted `newItems` run into the already-sorted, already-deduped
726
+ // `buffer`, dropping items equal on (blockNumber, logIndex, registration index).
727
+ // Single linear pass over both runs after ordering `newItems` in place; every
728
+ // comparison is a direct `compareBufferItem` call (V8 inlines it) rather than a
729
+ // callback through `Array.sort`.
730
+ let mergeIntoBuffer = (buffer: array<Internal.item>, newItems: array<Internal.item>): array<
731
+ Internal.item,
732
+ > => {
733
+ let n = newItems->Array.length
734
+ // Insertion sort: a source response is small and usually already ascending,
735
+ // so this is ~O(n) here.
736
+ for i in 1 to n - 1 {
737
+ let x = newItems->Array.getUnsafe(i)
738
+ let j = ref(i - 1)
739
+ while j.contents >= 0 && compareBufferItem(newItems->Array.getUnsafe(j.contents), x) > 0 {
740
+ newItems->Array.setUnsafe(j.contents + 1, newItems->Array.getUnsafe(j.contents))
741
+ j := j.contents - 1
742
+ }
743
+ newItems->Array.setUnsafe(j.contents + 1, x)
744
+ }
745
+
746
+ let m = buffer->Array.length
747
+ let merged = []
748
+ let last = ref(None)
749
+ let push = item =>
750
+ switch last.contents {
751
+ | Some(l) if compareBufferItem(l, item) === 0 => ()
752
+ | _ => {
753
+ merged->Array.push(item)
754
+ last := Some(item)
755
+ }
756
+ }
757
+
758
+ let i = ref(0)
759
+ let j = ref(0)
760
+ while i.contents < m && j.contents < n {
761
+ let a = buffer->Array.getUnsafe(i.contents)
762
+ let b = newItems->Array.getUnsafe(j.contents)
763
+ if compareBufferItem(a, b) <= 0 {
764
+ push(a)
765
+ i := i.contents + 1
766
+ } else {
767
+ push(b)
768
+ j := j.contents + 1
769
+ }
770
+ }
771
+ while i.contents < m {
772
+ push(buffer->Array.getUnsafe(i.contents))
773
+ i := i.contents + 1
627
774
  }
775
+ while j.contents < n {
776
+ push(newItems->Array.getUnsafe(j.contents))
777
+ j := j.contents + 1
778
+ }
779
+ merged
628
780
  }
629
781
 
630
782
  // Some big number which should be bigger than any log index
@@ -695,48 +847,46 @@ let updateInternal = (
695
847
  fetchState: t,
696
848
  ~optimizedPartitions=fetchState.optimizedPartitions,
697
849
  ~mutItems=?,
850
+ // Set when the caller already passes a sorted, deduped buffer (hot paths merge
851
+ // via mergeIntoBuffer or filter the sorted buffer). Otherwise mutItems is
852
+ // normalized here, so callers can hand over items in any order.
853
+ ~mutItemsSorted=false,
698
854
  ~blockLag=fetchState.blockLag,
699
855
  ~knownHeight=fetchState.knownHeight,
700
856
  ): t => {
701
- let mutItemsRef = ref(mutItems)
857
+ // The buffer to build on: the caller's items (normalized to sorted if needed),
858
+ // or the current buffer when only onBlock items change.
859
+ let base = switch mutItems {
860
+ | Some(items) => mutItemsSorted ? items : []->mergeIntoBuffer(items)
861
+ | None => fetchState.buffer
862
+ }
702
863
 
864
+ // onBlock items are generated as their own ascending (block, logIndex) run and
865
+ // folded into `base` by the single merge below.
866
+ let blockItems = []
703
867
  let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
704
868
  | [] => knownHeight
705
- | onBlockRegistrations => {
706
- // Calculate the max block number we are going to create items for
707
- // Use maxOnBlockBufferSize to get the last target item in the buffer
708
- //
709
- // mutItems is not very reliable, since it might not be sorted,
710
- // but the chances for it happen are very low and not critical
711
- //
712
- // All this needed to prevent OOM when adding too many block items to the queue
713
- let maxBlockNumber = switch switch mutItemsRef.contents {
714
- | Some(mutItems) => mutItems
715
- | None => fetchState.buffer
716
- }->Array.get(fetchState.maxOnBlockBufferSize - 1) {
717
- | Some(item) => item->Internal.getItemBlockNumber
718
- | None =>
719
- switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
720
- | None => knownHeight
721
- | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
722
- }
723
- }
724
-
725
- let mutItems = switch mutItemsRef.contents {
726
- | Some(mutItems) => mutItems
727
- | None => fetchState.buffer->Array.copy
869
+ | onBlockRegistrations =>
870
+ // Calculate the max block number we are going to create items for
871
+ // Use maxOnBlockBufferSize to get the last target item in the buffer
872
+ // (sorted, so this is the highest-block item within the buffer cap).
873
+ // All this needed to prevent OOM when adding too many block items to the queue
874
+ let maxBlockNumber = switch base->Array.get(fetchState.maxOnBlockBufferSize - 1) {
875
+ | Some(item) => item->Internal.getItemBlockNumber
876
+ | None =>
877
+ switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
878
+ | None => knownHeight
879
+ | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
728
880
  }
729
- mutItemsRef := Some(mutItems)
730
-
731
- appendOnBlockItems(
732
- ~mutItems,
733
- ~onBlockRegistrations,
734
- ~indexerStartBlock=fetchState.startBlock,
735
- ~fromBlock=fetchState.latestOnBlockBlockNumber,
736
- ~maxBlockNumber,
737
- ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
738
- )
739
881
  }
882
+ appendOnBlockItems(
883
+ ~mutItems=blockItems,
884
+ ~onBlockRegistrations,
885
+ ~indexerStartBlock=fetchState.startBlock,
886
+ ~fromBlock=fetchState.latestOnBlockBlockNumber,
887
+ ~maxBlockNumber,
888
+ ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
889
+ )
740
890
  }
741
891
 
742
892
  let updatedFetchState = {
@@ -751,15 +901,10 @@ let updateInternal = (
751
901
  latestOnBlockBlockNumber,
752
902
  blockLag,
753
903
  knownHeight,
754
- buffer: switch mutItemsRef.contents {
755
- // Theoretically it could be faster to asume that
756
- // the items are sorted, but there are cases
757
- // when the data source returns them unsorted
758
- | Some(mutItems) => {
759
- mutItems->Array.sort(compareBufferItem)
760
- mutItems
761
- }
762
- | None => fetchState.buffer
904
+ // Single merge point: fold any onBlock items into the sorted base buffer.
905
+ buffer: switch blockItems {
906
+ | [] => base
907
+ | blockItems => base->mergeIntoBuffer(blockItems)
763
908
  },
764
909
  firstEventBlock: fetchState.firstEventBlock,
765
910
  }
@@ -905,10 +1050,10 @@ OptimizedPartitions.t => {
905
1050
  addressesByContractName,
906
1051
  mergeBlock: None,
907
1052
  mutPendingQueries: [],
908
- prevQueryRange: 0,
909
- prevPrevQueryRange: 0,
910
- prevRangeSize: 0,
911
- latestBlockRangeUpdateBlock: 0,
1053
+ sourceRangeCapacity: 0,
1054
+ prevSourceRangeCapacity: 0,
1055
+ eventDensity: None,
1056
+ latestSourceRangeCapacityUpdateBlock: 0,
912
1057
  })
913
1058
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
914
1059
  }
@@ -1241,10 +1386,10 @@ let registerDynamicContracts = (
1241
1386
  addressesByContractName,
1242
1387
  mergeBlock: None,
1243
1388
  mutPendingQueries: p.mutPendingQueries,
1244
- prevQueryRange: p.prevQueryRange,
1245
- prevPrevQueryRange: p.prevPrevQueryRange,
1246
- prevRangeSize: p.prevRangeSize,
1247
- latestBlockRangeUpdateBlock: p.latestBlockRangeUpdateBlock,
1389
+ sourceRangeCapacity: p.sourceRangeCapacity,
1390
+ prevSourceRangeCapacity: p.prevSourceRangeCapacity,
1391
+ eventDensity: p.eventDensity,
1392
+ latestSourceRangeCapacityUpdateBlock: p.latestSourceRangeCapacityUpdateBlock,
1248
1393
  })
1249
1394
  }
1250
1395
  }
@@ -1276,6 +1421,26 @@ let registerDynamicContracts = (
1276
1421
  }
1277
1422
  }
1278
1423
 
1424
+ // Drop events an address-param filter rejects. A merged partition may
1425
+ // over-fetch a wildcard event whose indexed address param references an
1426
+ // address registered after the log's block; `clientAddressFilter` is the
1427
+ // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1428
+ let filterByClientAddress = (
1429
+ items: array<Internal.item>,
1430
+ ~indexingAddresses: IndexingAddresses.t,
1431
+ ): array<Internal.item> =>
1432
+ items->Array.filter(item =>
1433
+ switch item {
1434
+ | Internal.Event({payload, blockNumber}) as item =>
1435
+ switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1436
+ | Some(filter) =>
1437
+ filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1438
+ | None => true
1439
+ }
1440
+ | _ => true
1441
+ }
1442
+ )
1443
+
1279
1444
  /*
1280
1445
  Updates fetchState with a response for a given query.
1281
1446
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1284,26 +1449,10 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1284
1449
  */
1285
1450
  let handleQueryResult = (
1286
1451
  fetchState: t,
1287
- ~indexingAddresses: IndexingAddresses.t,
1288
1452
  ~query: query,
1289
1453
  ~latestFetchedBlock: blockNumberAndTimestamp,
1290
1454
  ~newItems,
1291
1455
  ): t => {
1292
- // Drop events an address-param filter rejects. A merged partition may
1293
- // over-fetch a wildcard event whose indexed address param references an
1294
- // address registered after the log's block; `clientAddressFilter` is the
1295
- // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1296
- let newItems = newItems->Array.filter(item =>
1297
- switch item {
1298
- | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1299
- switch onEventRegistration.clientAddressFilter {
1300
- | Some(filter) =>
1301
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1302
- | None => true
1303
- }
1304
- | _ => true
1305
- }
1306
- )
1307
1456
  fetchState->updateInternal(
1308
1457
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1309
1458
  ~query,
@@ -1311,10 +1460,14 @@ let handleQueryResult = (
1311
1460
  ~itemsCount=newItems->Array.length,
1312
1461
  ~latestFetchedBlock,
1313
1462
  ),
1463
+ // Merge the response into the sorted buffer, dropping duplicates an
1464
+ // overlapping query may re-deliver (e.g. an over-fetched log matched by two
1465
+ // partitions). Absorbs sorting too, so updateInternal doesn't re-sort.
1466
+ ~mutItemsSorted=true,
1314
1467
  ~mutItems=?{
1315
1468
  switch newItems {
1316
1469
  | [] => None
1317
- | _ => Some(fetchState.buffer->Array.concat(newItems))
1470
+ | _ => Some(fetchState.buffer->mergeIntoBuffer(newItems))
1318
1471
  }
1319
1472
  },
1320
1473
  )
@@ -1335,6 +1488,7 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1335
1488
  toBlock: q.toBlock,
1336
1489
  isChunk: q.isChunk,
1337
1490
  itemsTarget: q.itemsTarget,
1491
+ itemsEst: q.itemsEst,
1338
1492
  fetchedBlock: None,
1339
1493
  }
1340
1494
 
@@ -1356,61 +1510,129 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1356
1510
  }
1357
1511
 
1358
1512
  // Most parallel in-flight chunk queries a single partition may have at once.
1359
- let maxPendingChunksPerPartition = 10
1360
-
1361
- // Fills a gap range (a hole left between completed/pending chunks, e.g. from an
1362
- // out-of-order partial response) unconditionally — gaps are already-committed
1363
- // range, not subject to this tick's water-fill budget. Priced by the
1364
- // partition's trusted density when it has one; otherwise by the "available
1365
- // density" the partition's equal-divide budget spread over its remaining
1366
- // range this tick so a small gap reserves proportionally little instead of a
1367
- // noisy one-sample estimate. Chunks only on a trusted POSITIVE density, same
1368
- // rule as the water-fill. Returns the created queries' total itemsTarget.
1513
+ // Only queries still being fetched count — a fetched chunk parked behind a gap
1514
+ // doesn't hold a slot, so a slow query at the queue head can't starve the
1515
+ // partition's pipeline.
1516
+ let maxInFlightChunksPerPartition = 12
1517
+
1518
+ // Most parallel in-flight queries a single chain may have at once, across all
1519
+ // its partitions. Bounds source load on chains with many partitions, where the
1520
+ // per-partition cap alone would admit thousands of concurrent queries.
1521
+ let maxChainConcurrency = 100
1522
+
1523
+ // Chunk spans grow by this factor over the smallest recently observed source
1524
+ // range, so the pipeline keeps probing for more capacity instead of locking in
1525
+ // the first measurement.
1526
+ let chunkRangeGrowthFactor = 1.8
1527
+
1528
+ // Push one density-priced query and return its itemsEst. itemsEst is the honest
1529
+ // density estimate the chain's budget is reserved in; itemsTarget is the
1530
+ // server-side cap with chunkItemsMultiplier headroom so a denser-than-expected
1531
+ // range doesn't truncate the response.
1532
+ //
1533
+ // A bounded query (toBlock set) additionally floors its cap at
1534
+ // itemsTargetFloor: its range is already the hard bound on the response, so a
1535
+ // low density estimate shrinking the cap only buys self-truncated responses —
1536
+ // each one a wasted roundtrip that opens a gap and pollutes nothing but our
1537
+ // own pipeline. The floor is the indexer target split across the chain's
1538
+ // concurrency slots, so even every in-flight bounded query hitting its floored
1539
+ // cap at once overshoots the pool by at most ~one buffer target. Open-ended
1540
+ // queries keep the pure density cap — there it's the only bound at all.
1541
+ let pushDensityPricedQuery = (
1542
+ queries: array<query>,
1543
+ ~partitionId,
1544
+ ~fromBlock,
1545
+ ~toBlock,
1546
+ ~isChunk,
1547
+ ~density,
1548
+ ~chunkItemsMultiplier,
1549
+ ~chainTargetBlock,
1550
+ ~itemsTargetFloor,
1551
+ ~selection,
1552
+ ~addressesByContractName,
1553
+ ) => {
1554
+ let itemsEst = densityItemsTarget(~density, ~fromBlock, ~toBlock, ~chainTargetBlock)
1555
+ let itemsTarget = densityItemsTarget(
1556
+ ~density=density *. chunkItemsMultiplier,
1557
+ ~fromBlock,
1558
+ ~toBlock,
1559
+ ~chainTargetBlock,
1560
+ )
1561
+ queries
1562
+ ->Array.push({
1563
+ partitionId,
1564
+ fromBlock,
1565
+ toBlock,
1566
+ selection,
1567
+ isChunk,
1568
+ itemsTarget: switch toBlock {
1569
+ | Some(_) => Pervasives.max(itemsTarget, itemsTargetFloor)
1570
+ | None => itemsTarget
1571
+ },
1572
+ itemsEst,
1573
+ addressesByContractName,
1574
+ })
1575
+ ->ignore
1576
+ itemsEst
1577
+ }
1578
+
1579
+ // Generates candidate queries for a gap range (a hole left between
1580
+ // completed/pending chunks, e.g. from an out-of-order partial response). Gaps
1581
+ // carry the range's low fromBlock, so the acceptance pass takes them before
1582
+ // forward progress. Chunks only on a trusted POSITIVE density; a positive
1583
+ // density without capacity history prices a single query. Zero or unknown
1584
+ // density falls back to the "available density" — the partition's equal-divide
1585
+ // budget spread over its remaining range this tick. That fallback should be
1586
+ // unreachable (a gap implies pipelined chunks, which imply a positive observed
1587
+ // density), but if chunking preconditions ever change it must not price by a
1588
+ // zero density: that would produce an itemsTarget-1 query crawling a dense gap
1589
+ // one item per response.
1369
1590
  let pushGapFillQueries = (
1370
1591
  queries: array<query>,
1371
1592
  ~partitionId: string,
1372
1593
  ~rangeFromBlock: int,
1373
1594
  ~rangeEndBlock: option<int>,
1374
- ~knownHeight: int,
1595
+ ~headBlockNumber: int,
1375
1596
  ~chainTargetBlock: int,
1376
1597
  ~maybeChunkRange: option<int>,
1377
1598
  ~maxChunks: int,
1378
1599
  ~partition: partition,
1379
1600
  ~partitionBudget: float,
1601
+ ~chunkItemsMultiplier: float,
1602
+ ~itemsTargetFloor: int,
1380
1603
  ~selection: selection,
1381
1604
  ~addressesByContractName: dict<array<Address.t>>,
1382
1605
  ) => {
1383
- let cost = ref(0.)
1384
- if rangeFromBlock <= knownHeight && maxChunks > 0 {
1606
+ // Gaps past the chain's target block wait: they regenerate from the
1607
+ // pending-walk each tick and fill once the target reaches them. The lagged
1608
+ // head is the fetchable ceiling — blocks past knownHeight - blockLag can't
1609
+ // be queried yet.
1610
+ if rangeFromBlock <= Pervasives.min(headBlockNumber, chainTargetBlock) && maxChunks > 0 {
1385
1611
  switch rangeEndBlock {
1386
1612
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1387
1613
  | _ =>
1388
- let trustedDensity = partition->getTrustedDensity
1389
1614
  let maxBlock = switch rangeEndBlock {
1390
1615
  | Some(eb) => eb
1391
1616
  | None => chainTargetBlock
1392
1617
  }
1393
- let pushSingleQuery = (~density, ~isChunk) => {
1394
- let itemsTarget = densityItemsTarget(
1395
- ~density,
1618
+ let pushSingleQuery = (~density, ~isChunk) =>
1619
+ queries
1620
+ ->pushDensityPricedQuery(
1621
+ ~partitionId,
1396
1622
  ~fromBlock=rangeFromBlock,
1397
1623
  ~toBlock=rangeEndBlock,
1624
+ ~isChunk,
1625
+ ~density,
1626
+ ~chunkItemsMultiplier,
1398
1627
  ~chainTargetBlock,
1628
+ ~itemsTargetFloor,
1629
+ ~selection,
1630
+ ~addressesByContractName,
1399
1631
  )
1400
- queries->Array.push({
1401
- partitionId,
1402
- fromBlock: rangeFromBlock,
1403
- toBlock: rangeEndBlock,
1404
- selection,
1405
- isChunk,
1406
- itemsTarget,
1407
- addressesByContractName,
1408
- })
1409
- cost := cost.contents +. itemsTarget->Int.toFloat
1410
- }
1411
- switch (trustedDensity, maybeChunkRange) {
1632
+ ->ignore
1633
+ switch (partition->getTrustedDensity, maybeChunkRange) {
1412
1634
  | (Some(density), Some(chunkRange)) if density > 0. =>
1413
- let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 1.8)
1635
+ let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. chunkRangeGrowthFactor)
1414
1636
  if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
1415
1637
  let chunkFromBlock = ref(rangeFromBlock)
1416
1638
  let chunkIdx = ref(0)
@@ -1418,22 +1640,20 @@ let pushGapFillQueries = (
1418
1640
  chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1419
1641
  ) {
1420
1642
  let chunkToBlock = chunkFromBlock.contents + chunkSize - 1
1421
- let itemsTarget = densityItemsTarget(
1422
- ~density,
1643
+ queries
1644
+ ->pushDensityPricedQuery(
1645
+ ~partitionId,
1423
1646
  ~fromBlock=chunkFromBlock.contents,
1424
1647
  ~toBlock=Some(chunkToBlock),
1648
+ ~isChunk=true,
1649
+ ~density,
1650
+ ~chunkItemsMultiplier,
1425
1651
  ~chainTargetBlock,
1652
+ ~itemsTargetFloor,
1653
+ ~selection,
1654
+ ~addressesByContractName,
1426
1655
  )
1427
- queries->Array.push({
1428
- partitionId,
1429
- fromBlock: chunkFromBlock.contents,
1430
- toBlock: Some(chunkToBlock),
1431
- isChunk: true,
1432
- selection,
1433
- itemsTarget,
1434
- addressesByContractName,
1435
- })
1436
- cost := cost.contents +. itemsTarget->Int.toFloat
1656
+ ->ignore
1437
1657
  chunkFromBlock := chunkToBlock + 1
1438
1658
  chunkIdx := chunkIdx.contents + 1
1439
1659
  }
@@ -1441,56 +1661,283 @@ let pushGapFillQueries = (
1441
1661
  // Not enough room for 2 chunks, fall back to a single query
1442
1662
  pushSingleQuery(~density, ~isChunk=rangeEndBlock !== None)
1443
1663
  }
1444
- | (Some(density), _) => pushSingleQuery(~density, ~isChunk=false)
1445
- | (None, _) =>
1664
+ | (Some(density), None) if density > 0. => pushSingleQuery(~density, ~isChunk=false)
1665
+ | _ =>
1446
1666
  let remainingRange = Pervasives.max(1, chainTargetBlock - rangeFromBlock + 1)
1447
1667
  pushSingleQuery(~density=partitionBudget /. remainingRange->Int.toFloat, ~isChunk=false)
1448
1668
  }
1449
1669
  }
1450
1670
  }
1451
- cost.contents
1452
- }
1453
-
1454
- // The level every partition ends at when `budget` is poured across partitions
1455
- // already holding `footprints`: the unique L with Σ max(0, L - fᵢ) = budget.
1456
- // Partitions above L get nothing (their head start is their share); the rest
1457
- // are topped up exactly to L, so the pour equals the budget no matter how
1458
- // uneven the footprints are.
1459
- let waterLevel = (~budget: float, ~footprints: array<float>) => {
1460
- let sorted = footprints->Array.toSorted(Float.compare)
1461
- let n = sorted->Array.length
1462
- let prefix = ref(0.)
1463
- let level = ref(None)
1464
- let idx = ref(0)
1465
- while level.contents == None && idx.contents < n {
1466
- let i = idx.contents
1467
- prefix := prefix.contents +. sorted->Array.getUnsafe(i)
1468
- // The level if only the i+1 lowest footprints receive water — correct once
1469
- // it doesn't reach the next footprint up.
1470
- let candidate = (budget +. prefix.contents) /. (i + 1)->Int.toFloat
1471
- if i == n - 1 || candidate <= sorted->Array.getUnsafe(i + 1) {
1472
- level := Some(candidate)
1473
- }
1474
- idx := idx.contents + 1
1475
- }
1476
- level.contents->Option.getOr(0.)
1477
1671
  }
1478
1672
 
1479
- // Per-partition mutable state threaded through the water-fill rounds below.
1480
- type waterFillState = {
1673
+ // Per-partition state carried from the gap-fill/cursor walk to candidate
1674
+ // generation.
1675
+ type partitionFillState = {
1481
1676
  partitionId: string,
1482
1677
  p: partition,
1483
- mutable cursor: int,
1484
- mutable chunksUsedThisCall: int,
1485
- // Existing mutPendingQueries count before this call — fixed for the call,
1486
- // used with chunksUsedThisCall against maxPendingChunksPerPartition.
1487
- pendingCount: int,
1678
+ cursor: int,
1679
+ // Chunks already generated for this partition during gap-fill — used with
1680
+ // inFlightCount against maxInFlightChunksPerPartition.
1681
+ chunksUsedThisCall: int,
1682
+ // Still-being-fetched pending queries before this call — fixed for the call.
1683
+ // A fetched chunk parked behind a gap doesn't hold a pipeline slot.
1684
+ inFlightCount: int,
1488
1685
  queryEndBlock: option<int>,
1489
1686
  maybeChunkRange: option<int>,
1490
- // This partition's own slot in queriesByPartitionIndex — gap-fill and every
1491
- // water-fill round push here directly, so query order falls out of
1492
- // idsInAscOrder for free (see getNextQuery) instead of a final sort pass.
1493
- bucket: array<query>,
1687
+ }
1688
+
1689
+ // Gap-fill: walk one partition's pending queries, generating a candidate for
1690
+ // any hole (e.g. from an out-of-order partial chunk response). Returns the
1691
+ // partition's post-gap fill state — its cursor for forward work — or None when
1692
+ // the partition is blocked on an unresolved single-shot query.
1693
+ let walkPartitionPending = (
1694
+ p: partition,
1695
+ ~partitionId: string,
1696
+ ~inFlightCount: int,
1697
+ ~candidates: array<query>,
1698
+ ~headBlockNumber: int,
1699
+ ~chainTargetBlock: int,
1700
+ ~chunkItemsMultiplier: float,
1701
+ ~itemsTargetFloor: int,
1702
+ ~partitionBudget: float,
1703
+ ~queryEndBlock: option<int>,
1704
+ ): option<partitionFillState> => {
1705
+ let maybeChunkRange = getMinHistoryRange(p)
1706
+ let pendingCount = p.mutPendingQueries->Array.length
1707
+
1708
+ let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1709
+ let canContinue = ref(true)
1710
+ let chunksUsedThisCall = ref(0)
1711
+ let pqIdx = ref(0)
1712
+ while pqIdx.contents < pendingCount && canContinue.contents {
1713
+ let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1714
+
1715
+ if pq.fromBlock > cursor.contents {
1716
+ let beforeLen = candidates->Array.length
1717
+ pushGapFillQueries(
1718
+ candidates,
1719
+ ~partitionId,
1720
+ ~rangeFromBlock=cursor.contents,
1721
+ ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1722
+ ~headBlockNumber,
1723
+ ~chainTargetBlock,
1724
+ ~maybeChunkRange,
1725
+ ~maxChunks=maxInFlightChunksPerPartition - inFlightCount - chunksUsedThisCall.contents,
1726
+ ~partition=p,
1727
+ ~partitionBudget,
1728
+ ~chunkItemsMultiplier,
1729
+ ~itemsTargetFloor,
1730
+ ~selection=p.selection,
1731
+ ~addressesByContractName=p.addressesByContractName,
1732
+ )
1733
+ chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
1734
+ }
1735
+ switch pq {
1736
+ | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
1737
+ if blockNumber < toBlock =>
1738
+ cursor := blockNumber + 1
1739
+ | {isChunk: true, toBlock: Some(toBlock)} => cursor := toBlock + 1
1740
+ | _ => canContinue := false
1741
+ }
1742
+ pqIdx := pqIdx.contents + 1
1743
+ }
1744
+
1745
+ canContinue.contents
1746
+ ? Some({
1747
+ partitionId,
1748
+ p,
1749
+ cursor: cursor.contents,
1750
+ chunksUsedThisCall: chunksUsedThisCall.contents,
1751
+ inFlightCount,
1752
+ queryEndBlock,
1753
+ maybeChunkRange,
1754
+ })
1755
+ : None
1756
+ }
1757
+
1758
+ // Forward work: generate each in-range partition's candidates — strict chunks
1759
+ // when both source-capacity history and density are known, or an open-ended
1760
+ // budget probe otherwise. No budget check here; the acceptance pass decides
1761
+ // which candidates make the cut.
1762
+ //
1763
+ // Chunks require a POSITIVE trusted density: density 0 prices every chunk at
1764
+ // ~nothing, so an open-ended probe (full server scan range in one response)
1765
+ // beats a pipeline of hard-bounded chunks that crawl chunkRangeGrowthFactor×
1766
+ // per two responses.
1767
+ let pushForwardCandidates = (
1768
+ candidates: array<query>,
1769
+ // May be truncated to the chain's free concurrency slots — a pure generation
1770
+ // bound, see getNextQuery.
1771
+ ~inRangeStates: array<partitionFillState>,
1772
+ // The full in-range partition count, pre-truncation. Probe sizing divides by
1773
+ // this so each probe's itemsEst/itemsTarget stays the honest per-partition
1774
+ // share for budget control — sizing by the (fewer) admittable queries would
1775
+ // let every accepted probe over-fetch its share.
1776
+ ~inRangeCount: int,
1777
+ ~chainTargetBlock: int,
1778
+ ~freshBudget: float,
1779
+ ~chunkItemsMultiplier: float,
1780
+ ~itemsTargetFloor: int,
1781
+ ) => {
1782
+ // Even share of the fresh budget across the partitions actually fetching
1783
+ // this tick (not every partition — so budget isn't stranded on ones below
1784
+ // the head, waiting, or already done). The fallback when there's no range to
1785
+ // the target.
1786
+ let probeShare = inRangeCount == 0 ? 0. : freshBudget /. inRangeCount->Int.toFloat
1787
+ // Items/block the budget implies over the range those partitions cover this
1788
+ // tick — from the furthest-behind in-range cursor to the target. A probe
1789
+ // covering less of that range (its partition sits further ahead) gets
1790
+ // proportionally fewer items; one starting at the frontier gets the full
1791
+ // even share.
1792
+ let frontierCursor =
1793
+ inRangeStates->Array.reduce(chainTargetBlock, (min, fs) => fs.cursor < min ? fs.cursor : min)
1794
+ let rangeToTarget = chainTargetBlock - frontierCursor + 1
1795
+ let rangeTargetDensity =
1796
+ inRangeCount > 0 && rangeToTarget > 0 ? freshBudget /. rangeToTarget->Int.toFloat : 0.
1797
+
1798
+ inRangeStates->Array.forEach(fs => {
1799
+ let p = fs.p
1800
+ let maxBlock = switch fs.queryEndBlock {
1801
+ | Some(eb) => eb
1802
+ | None => chainTargetBlock
1803
+ }
1804
+ switch (fs.maybeChunkRange, p->getTrustedDensity) {
1805
+ | (Some(minHistoryRange), Some(density)) if density > 0. =>
1806
+ let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. chunkRangeGrowthFactor)
1807
+ let maxChunksRemaining =
1808
+ maxInFlightChunksPerPartition - fs.inFlightCount - fs.chunksUsedThisCall
1809
+ // No chunk starts past chainTargetBlock; an emitted chunk still keeps
1810
+ // its full span (chunkToBlock may exceed the target — only
1811
+ // endBlock/mergeBlock are hard bounds).
1812
+ let chunkStartCeiling = Pervasives.min(maxBlock, chainTargetBlock)
1813
+ let created = ref(0)
1814
+ let chunkFromBlock = ref(fs.cursor)
1815
+ // Stop once this partition alone has generated more than the whole fresh
1816
+ // budget: the acceptance pass can hand a single partition at most the
1817
+ // budget plus one overshoot query, so further chunks could never be
1818
+ // accepted. Bounds generation (and the candidate sort) when the budget
1819
+ // is small relative to the pipeline cap.
1820
+ let generatedItems = ref(0.)
1821
+ while (
1822
+ created.contents < maxChunksRemaining &&
1823
+ chunkFromBlock.contents <= chunkStartCeiling &&
1824
+ generatedItems.contents <= freshBudget
1825
+ ) {
1826
+ let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1827
+ let itemsEst =
1828
+ candidates->pushDensityPricedQuery(
1829
+ ~partitionId=fs.partitionId,
1830
+ ~fromBlock=chunkFromBlock.contents,
1831
+ ~toBlock=Some(chunkToBlock),
1832
+ ~isChunk=true,
1833
+ ~density,
1834
+ ~chunkItemsMultiplier,
1835
+ ~chainTargetBlock,
1836
+ ~itemsTargetFloor,
1837
+ ~selection=p.selection,
1838
+ ~addressesByContractName=p.addressesByContractName,
1839
+ )
1840
+ generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
1841
+ chunkFromBlock := chunkToBlock + 1
1842
+ created := created.contents + 1
1843
+ }
1844
+ | _ =>
1845
+ // Size the probe by the events its range to the target is expected to
1846
+ // hold — rangeTargetDensity × (chainTargetBlock − fromBlock + 1), split
1847
+ // across the partitions fetching this tick. With no range to the target
1848
+ // fall back to an even share of the fresh budget, so cold chains and
1849
+ // caught-up partitions still probe.
1850
+ let itemsTarget = if rangeToTarget > 0 {
1851
+ Pervasives.max(
1852
+ 1,
1853
+ Math.round(
1854
+ rangeTargetDensity *.
1855
+ (chainTargetBlock - fs.cursor + 1)->Int.toFloat /.
1856
+ inRangeCount->Int.toFloat,
1857
+ )->Float.toInt,
1858
+ )
1859
+ } else {
1860
+ Pervasives.max(1, Math.round(probeShare)->Float.toInt)
1861
+ }
1862
+ candidates
1863
+ ->Array.push({
1864
+ partitionId: fs.partitionId,
1865
+ fromBlock: fs.cursor,
1866
+ toBlock: fs.queryEndBlock,
1867
+ isChunk: false,
1868
+ selection: p.selection,
1869
+ itemsTarget,
1870
+ itemsEst: itemsTarget,
1871
+ addressesByContractName: p.addressesByContractName,
1872
+ })
1873
+ ->ignore
1874
+ }
1875
+ })
1876
+ }
1877
+
1878
+ // Acceptance: merge fresh candidates (Some) with the in-flight reservations
1879
+ // (None) and walk them in fromBlock order, starting from the full
1880
+ // chainTargetItems. A reservation just draws down the budget — its query is
1881
+ // already sent — while a candidate draws down the budget and is emitted.
1882
+ // Because a gap-fill's fromBlock precedes the in-flight query it unblocks,
1883
+ // it claims budget ahead of that reservation, so the buffer never deadlocks
1884
+ // waiting on a hole it can't fund. The candidate that tips the budget
1885
+ // negative is still emitted (a single overshoot); everything after it waits
1886
+ // for a tick with more budget. Accepted queries route back to their
1887
+ // partition bucket, so the output stays in idsInAscOrder with each
1888
+ // partition's queries in fromBlock order.
1889
+ let acceptCandidates = (
1890
+ ~candidates: array<query>,
1891
+ ~reservations: array<(int, int)>,
1892
+ ~chainTargetItems: float,
1893
+ ~partitionIndexById: dict<int>,
1894
+ ~queriesByPartitionIndex: array<array<query>>,
1895
+ ) => {
1896
+ let acceptanceStream = []
1897
+ candidates->Array.forEach(query =>
1898
+ acceptanceStream->Array.push((query.fromBlock, query.itemsEst, Some(query)))->ignore
1899
+ )
1900
+ reservations->Array.forEach(((fromBlock, itemsEst)) =>
1901
+ acceptanceStream->Array.push((fromBlock, itemsEst, None))->ignore
1902
+ )
1903
+ // Sort by fromBlock; on a tie charge the in-flight reservation (None) before
1904
+ // a fresh candidate (Some), so a same-block candidate can't overshoot the
1905
+ // target buffer. Only a strictly-earlier candidate — a gap-fill, whose
1906
+ // fromBlock precedes the query it unblocks — borrows ahead of a reservation.
1907
+ acceptanceStream->Array.sort(((aFrom, _, aQuery), (bFrom, _, bQuery)) =>
1908
+ if aFrom !== bFrom {
1909
+ Int.compare(aFrom, bFrom)
1910
+ } else {
1911
+ switch (aQuery, bQuery) {
1912
+ | (None, Some(_)) => Ordering.less
1913
+ | (Some(_), None) => Ordering.greater
1914
+ | (None, None) | (Some(_), Some(_)) => Ordering.equal
1915
+ }
1916
+ }
1917
+ )
1918
+ let streamCount = acceptanceStream->Array.length
1919
+ let remainingBudget = ref(chainTargetItems)
1920
+ let acceptIdx = ref(0)
1921
+ // In-flight queries count against the chain's concurrency cap alongside the
1922
+ // ones accepted this tick; once the cap is reached no later candidate can be
1923
+ // accepted (they're only later in fromBlock order), so the walk stops.
1924
+ let usedConcurrency = ref(reservations->Array.length)
1925
+ while (
1926
+ remainingBudget.contents > 0. &&
1927
+ acceptIdx.contents < streamCount &&
1928
+ usedConcurrency.contents < maxChainConcurrency
1929
+ ) {
1930
+ let (_, itemsEst, maybeQuery) = acceptanceStream->Array.getUnsafe(acceptIdx.contents)
1931
+ switch maybeQuery {
1932
+ | Some(query) =>
1933
+ let partitionIdx = partitionIndexById->Dict.getUnsafe(query.partitionId)
1934
+ queriesByPartitionIndex->Array.getUnsafe(partitionIdx)->Array.push(query)->ignore
1935
+ usedConcurrency := usedConcurrency.contents + 1
1936
+ | None => ()
1937
+ }
1938
+ remainingBudget := remainingBudget.contents -. itemsEst->Int.toFloat
1939
+ acceptIdx := acceptIdx.contents + 1
1940
+ }
1494
1941
  }
1495
1942
 
1496
1943
  // Candidate queries are sized against chainTargetBlock, the soft querying
@@ -1501,28 +1948,33 @@ type waterFillState = {
1501
1948
  // query with no other ceiling: the true hard bounds stay
1502
1949
  // endBlock/mergeBlock/the lagged head.
1503
1950
  //
1504
- // In-range partitions share chainTargetItems (this chain's target total
1505
- // footprint in-flight plus new) by water-fill: each round pours exactly the
1506
- // remaining fresh budget at the level computed by waterLevel, so a partition
1507
- // already holding more than the level (from earlier ticks' in-flight queries)
1508
- // gets nothing and its implicit share flows to the others, while totals stay
1509
- // even and the pour never exceeds the budget. Rounds repeat only because
1510
- // emits are quantized: a partition may consume less than its allotment (range
1511
- // or chunk-cap runs out) or slightly more (min one chunk), and the leftover
1512
- // re-pours over whoever still has range left.
1951
+ // The tick's budget is chainTargetItems minus what's already in flight. A
1952
+ // non-positive budget only resolves the wait action and generates no query
1953
+ // candidates. With a positive budget, every candidate query gap-fill holes,
1954
+ // plus each in-range partition's chunks or open-ended probe toward the target —
1955
+ // is generated with no budget check, then the candidates are sorted by
1956
+ // fromBlock and accepted in that order while the budget stays positive. The
1957
+ // query that tips it negative is still accepted (a single overshoot);
1958
+ // everything after it waits for a tick with more budget.
1959
+ // Sorting by fromBlock spends the budget on the earliest blocks across all
1960
+ // partitions first, so no partition is starved by generation order and the
1961
+ // frontier advances evenly. In-flight reservations release as responses land,
1962
+ // so acceptance redistributes across ticks.
1513
1963
  //
1514
- // A partition with a trusted positive density (two or more responses — see
1515
- // getMinHistoryRange) always emits at least one full-size chunk/query once
1516
- // given an allotment, sized by its real density may overshoot the
1517
- // allotment by at most one chunk (the server also enforces itemsTarget via a
1518
- // maxNumLogs-style cap, so an overshoot truncates the response rather than
1519
- // the buffer). Any other partition (no signal, one noisy sample, or a
1520
- // density-0 estimate) emits one open-ended probe sized exactly at its
1521
- // allotment instead.
1964
+ // A partition with source-capacity history and a positive density generates
1965
+ // density-sized chunks toward the target. Any other partition (no signal, no
1966
+ // capacity history, or a density-0 estimate) generates one open-ended probe
1967
+ // sized to the events its range to the target is expected to hold
1968
+ // rangeTargetDensity × (chainTargetBlock fromBlock + 1) / inRangeCount — so
1969
+ // unknown-density partitions probe in parallel within one budget.
1522
1970
  let getNextQuery = (
1523
1971
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1524
1972
  ~chainTargetBlock: int,
1525
1973
  ~chainTargetItems: float,
1974
+ ~chunkItemsMultiplier: float=1.,
1975
+ // Floor for bounded queries' server cap (see pushDensityPricedQuery) —
1976
+ // targetBufferSize / maxChainConcurrency from the cross-chain scheduler.
1977
+ ~itemsTargetFloor: int=0,
1526
1978
  ) => {
1527
1979
  let headBlockNumber = knownHeight - blockLag
1528
1980
  if headBlockNumber <= 0 {
@@ -1539,12 +1991,40 @@ let getNextQuery = (
1539
1991
 
1540
1992
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1541
1993
 
1542
- // Every partition is visited once here regardless of whether it gets a
1543
- // query pushed below — waiting-for-new-block bookkeeping shouldn't depend
1544
- // on this tick's budget.
1994
+ // Single scan over every partition, regardless of this tick's budget:
1995
+ // waiting-for-new-block bookkeeping, in-flight accounting, and the
1996
+ // id index routing the acceptance pass uses.
1997
+ //
1998
+ // In-flight means fetchedBlock === None: a query whose response already
1999
+ // landed has had its reservation released by ChainState even while it
2000
+ // lingers in mutPendingQueries behind an unfilled gap, so counting it would
2001
+ // understate the budget and hold a concurrency slot it no longer uses.
2002
+ let inFlightCounts = Utils.Array.jsArrayCreate(partitionsCount)
2003
+ // (fromBlock, itemsEst) of each still-in-flight query. The acceptance pass
2004
+ // merges these into the candidate stream and draws them down in fromBlock
2005
+ // order, so a gap-fill sitting before an in-flight query claims budget ahead
2006
+ // of it and the buffer unblocks without waiting for that query to return.
2007
+ let reservations = []
2008
+ // In-flight itemsEst summed over the reservations. Sizes fresh forward
2009
+ // work below.
2010
+ let chainReserved = ref(0.)
2011
+ // Position of each partition in idsInAscOrder, so an accepted query routes
2012
+ // back to its bucket and the output stays in idsInAscOrder.
2013
+ let partitionIndexById = Dict.make()
1545
2014
  for idx in 0 to partitionsCount - 1 {
1546
2015
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1547
2016
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
2017
+ partitionIndexById->Dict.set(partitionId, idx)
2018
+ let inFlightCount = ref(0)
2019
+ for pqIdx in 0 to p.mutPendingQueries->Array.length - 1 {
2020
+ let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx)
2021
+ if pq.fetchedBlock === None {
2022
+ inFlightCount := inFlightCount.contents + 1
2023
+ chainReserved := chainReserved.contents +. pq.itemsEst->Int.toFloat
2024
+ reservations->Array.push((pq.fromBlock, pq.itemsEst))->ignore
2025
+ }
2026
+ }
2027
+ inFlightCounts->Array.setUnsafe(idx, inFlightCount.contents)
1548
2028
  if (
1549
2029
  p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1550
2030
  ) {
@@ -1557,8 +2037,20 @@ let getNextQuery = (
1557
2037
  }
1558
2038
  }
1559
2039
 
1560
- // One bucket per partition, in idsInAscOrder order gap-fill and
1561
- // water-fill both push into a partition's own bucket, so flattening at
2040
+ // Fresh queries the acceptance pass can still admit this tick.
2041
+ let availableConcurrency = maxChainConcurrency - reservations->Array.length
2042
+
2043
+ // A zero budget is an admission check: preserve the wait-state scan above,
2044
+ // but make every query-generation pass below empty. Caught-up chains also
2045
+ // skip those passes because their action is already known. Same when the
2046
+ // chain is at its concurrency cap — no candidate could be accepted.
2047
+ let partitionsCount =
2048
+ chainTargetItems <= 0. || shouldWaitForNewBlock.contents || availableConcurrency <= 0
2049
+ ? 0
2050
+ : partitionsCount
2051
+
2052
+ // One bucket per partition, in idsInAscOrder order — gap-fill and the
2053
+ // budget pass both push into a partition's own bucket, so flattening at
1562
2054
  // the end (see below) reproduces idsInAscOrder without a sort.
1563
2055
  let queriesByPartitionIndex: array<
1564
2056
  array<query>,
@@ -1576,236 +2068,87 @@ let getNextQuery = (
1576
2068
  }
1577
2069
  }
1578
2070
 
1579
- // Each partition's existing in-flight itemsTarget, summed once and reused
1580
- // both for chainReserved (the call-wide fresh-budget ceiling below) and to
1581
- // seed each partition's water-fill footprint so a partition already
1582
- // holding in-flight queries is counted toward its even share and doesn't
1583
- // get topped up past the others.
1584
- let existingReservedByPartition = Dict.make()
1585
- let chainReserved = ref(0.)
1586
- for idx in 0 to partitionsCount - 1 {
1587
- let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1588
- let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1589
- let cost = ref(0.)
1590
- for pqIdx in 0 to p.mutPendingQueries->Array.length - 1 {
1591
- cost :=
1592
- cost.contents +. (p.mutPendingQueries->Array.getUnsafe(pqIdx)).itemsTarget->Int.toFloat
1593
- }
1594
- existingReservedByPartition->Dict.set(partitionId, cost.contents)
1595
- chainReserved := chainReserved.contents +. cost.contents
1596
- }
2071
+ // Every candidate query for this tick — gap-fill holes plus each in-range
2072
+ // partition's chunks/probe toward the target generated with no budget
2073
+ // check, then merged with the in-flight reservations, sorted by fromBlock,
2074
+ // and accepted while the budget lasts (acceptCandidates). Selecting by
2075
+ // fromBlock spends the budget on the earliest blocks across all partitions
2076
+ // first, so no partition is starved by iteration order and the frontier
2077
+ // advances evenly.
2078
+ let candidates = []
2079
+
2080
+ // Each partition's equal-divide share of the tick's budget, used to price
2081
+ // unknown-density gap probes.
2082
+ let partitionBudget =
2083
+ partitionsCount == 0 ? 0. : chainTargetItems /. partitionsCount->Int.toFloat
1597
2084
 
1598
- // Phase A: gap-fill. Walk each partition's pending queries once,
1599
- // unconditionally filling any hole (e.g. from an out-of-order partial
1600
- // chunk response) — uncapped, same as before this tick's water-fill was
1601
- // introduced. This also determines each partition's post-gap cursor and
1602
- // whether it's blocked on an unresolved single-shot query.
1603
2085
  let fillStates = []
1604
- let gapFillCost = ref(0.)
1605
2086
  for idx in 0 to partitionsCount - 1 {
1606
2087
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1607
2088
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1608
- let bucket = queriesByPartitionIndex->Array.getUnsafe(idx)
1609
- let pendingCount = p.mutPendingQueries->Array.length
1610
- let queryEndBlock = computeQueryEndBlock(p)
1611
- let maybeChunkRange = getMinHistoryRange(p)
1612
-
1613
- let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1614
- let canContinue = ref(true)
1615
- let chunksUsedThisCall = ref(0)
1616
- let pqIdx = ref(0)
1617
- while pqIdx.contents < pendingCount && canContinue.contents {
1618
- let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1619
-
1620
- if pq.fromBlock > cursor.contents {
1621
- let beforeLen = bucket->Array.length
1622
- let cost = pushGapFillQueries(
1623
- bucket,
1624
- ~partitionId,
1625
- ~rangeFromBlock=cursor.contents,
1626
- ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1627
- ~knownHeight,
1628
- ~chainTargetBlock,
1629
- ~maybeChunkRange,
1630
- ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1631
- ~partition=p,
1632
- ~partitionBudget=chainTargetItems /. partitionsCount->Int.toFloat,
1633
- ~selection=p.selection,
1634
- ~addressesByContractName=p.addressesByContractName,
1635
- )
1636
- chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1637
- gapFillCost := gapFillCost.contents +. cost
1638
- }
1639
- switch pq {
1640
- | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
1641
- if blockNumber < toBlock =>
1642
- cursor := blockNumber + 1
1643
- | {isChunk: true, toBlock: Some(toBlock)} => cursor := toBlock + 1
1644
- | _ => canContinue := false
1645
- }
1646
- pqIdx := pqIdx.contents + 1
1647
- }
1648
-
1649
- if canContinue.contents {
1650
- fillStates
1651
- ->Array.push({
1652
- partitionId,
1653
- p,
1654
- cursor: cursor.contents,
1655
- chunksUsedThisCall: chunksUsedThisCall.contents,
1656
- pendingCount,
1657
- queryEndBlock,
1658
- maybeChunkRange,
1659
- bucket,
1660
- })
1661
- ->ignore
2089
+ switch p->walkPartitionPending(
2090
+ ~partitionId,
2091
+ ~inFlightCount=inFlightCounts->Array.getUnsafe(idx),
2092
+ ~candidates,
2093
+ ~headBlockNumber,
2094
+ ~chainTargetBlock,
2095
+ ~chunkItemsMultiplier,
2096
+ ~itemsTargetFloor,
2097
+ ~partitionBudget,
2098
+ ~queryEndBlock=computeQueryEndBlock(p),
2099
+ ) {
2100
+ | Some(fillState) => fillStates->Array.push(fillState)->ignore
2101
+ | None => ()
1662
2102
  }
1663
2103
  }
1664
2104
 
1665
- // Fresh budget for this call what's left of chainTargetItems after
1666
- // existing in-flight queries and this tick's gap-fill. The water-fill
1667
- // rounds below hand it out; a partition already holding a large share
1668
- // (seeded below) gets proportionally less so totals stay even.
1669
- let rangeItemsTarget = Pervasives.max(
1670
- 0.,
1671
- chainTargetItems -. chainReserved.contents -. gapFillCost.contents,
1672
- )
1673
-
1674
- // Each in-range partition's running footprint: existing in-flight plus any
1675
- // gap-fill just pushed into its bucket. The water-fill levels every
1676
- // partition up toward a shared line, so this seed is what a partition
1677
- // starts the fill already holding.
1678
- let reservedByPartition = Dict.make()
1679
- fillStates->Array.forEach(fs => {
1680
- let cost = ref(existingReservedByPartition->Dict.getUnsafe(fs.partitionId))
1681
- for i in 0 to fs.bucket->Array.length - 1 {
1682
- cost := cost.contents +. (fs.bucket->Array.getUnsafe(i)).itemsTarget->Int.toFloat
1683
- }
1684
- reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1685
- })
2105
+ // Budget for fresh forward work: chainTargetItems minus what's still in
2106
+ // flight. Sizes probes and bounds chunk generation; the acceptance pass
2107
+ // does the final budgeting against the full chainTargetItems.
2108
+ let freshBudget = Pervasives.max(0., chainTargetItems -. chainReserved.contents)
1686
2109
 
1687
- let isInRange = (fs: waterFillState) =>
2110
+ let isInRange = (fs: partitionFillState) =>
1688
2111
  fs.cursor <= chainTargetBlock &&
1689
2112
  switch fs.queryEndBlock {
1690
2113
  | Some(eb) => fs.cursor <= eb
1691
2114
  | None => true
1692
2115
  } &&
1693
- fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
2116
+ fs.inFlightCount + fs.chunksUsedThisCall < maxInFlightChunksPerPartition
1694
2117
 
1695
2118
  let inRangeStates = fillStates->Array.filter(isInRange)
1696
-
1697
- // Emits this round's queries for one partition, given its water-fill
1698
- // allotment. Mutates fs.cursor/chunksUsedThisCall and returns the
1699
- // itemsTarget consumed.
1700
- //
1701
- // Chunks require a POSITIVE trusted density: density 0 prices every chunk
1702
- // at ~nothing, letting a partition flood its full chunk pipeline with
1703
- // hard-bounded queries that crawl 1.8× per two responses an open-ended
1704
- // probe instead gets the server's full scan range in one response.
1705
- let emitQueries = (fs: waterFillState, ~budget: float) => {
1706
- let p = fs.p
1707
- let maxBlock = switch fs.queryEndBlock {
1708
- | Some(eb) => eb
1709
- | None => chainTargetBlock
1710
- }
1711
- switch (fs.maybeChunkRange, p->getTrustedDensity) {
1712
- | (Some(minHistoryRange), Some(density)) if density > 0. =>
1713
- // Chunking active: strict chunks with a hard endBlock, uncapped real
1714
- // density — at least one full chunk this round even if budget falls
1715
- // short.
1716
- let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1717
- let chunkCost = density *. chunkSize->Int.toFloat
1718
- let maxChunksRemaining =
1719
- maxPendingChunksPerPartition - fs.pendingCount - fs.chunksUsedThisCall
1720
- let affordable = Math.floor(budget /. chunkCost)->Float.toInt
1721
- let numChunks = Pervasives.max(1, Pervasives.min(affordable, maxChunksRemaining))
1722
- let consumed = ref(0.)
1723
- let created = ref(0)
1724
- let chunkFromBlock = ref(fs.cursor)
1725
- while created.contents < numChunks && chunkFromBlock.contents <= maxBlock {
1726
- let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1727
- let itemsTarget = Pervasives.max(
1728
- 1,
1729
- (density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat)
1730
- ->Math.ceil
1731
- ->Float.toInt,
1732
- )
1733
- fs.bucket->Array.push({
1734
- partitionId: fs.partitionId,
1735
- fromBlock: chunkFromBlock.contents,
1736
- toBlock: Some(chunkToBlock),
1737
- isChunk: true,
1738
- selection: p.selection,
1739
- itemsTarget,
1740
- addressesByContractName: p.addressesByContractName,
1741
- })
1742
- consumed := consumed.contents +. itemsTarget->Int.toFloat
1743
- chunkFromBlock := chunkToBlock + 1
1744
- created := created.contents + 1
1745
- }
1746
- fs.cursor = chunkFromBlock.contents
1747
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + created.contents
1748
- consumed.contents
1749
- | _ =>
1750
- let itemsTarget = Pervasives.max(1, Math.round(budget)->Float.toInt)
1751
- fs.bucket->Array.push({
1752
- partitionId: fs.partitionId,
1753
- fromBlock: fs.cursor,
1754
- toBlock: fs.queryEndBlock,
1755
- isChunk: false,
1756
- selection: p.selection,
1757
- itemsTarget,
1758
- addressesByContractName: p.addressesByContractName,
1759
- })
1760
- fs.cursor = maxBlock + 1
1761
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + 1
1762
- itemsTarget->Int.toFloat
1763
- }
2119
+ let inRangeCount = inRangeStates->Array.length
2120
+ // The acceptance pass admits at most availableConcurrency fresh queries, in
2121
+ // fromBlock order, and every kept partition contributes a candidate at its
2122
+ // own cursor — so a partition past the first availableConcurrency in cursor
2123
+ // order could only offer candidates behind at least that many earlier ones,
2124
+ // which the concurrency cap stops the walk from ever reaching. Dropping
2125
+ // those partitions up front skips pointless candidate generation. A pure
2126
+ // generation bound: sizing still divides by the full inRangeCount, so each
2127
+ // probe keeps its honest per-partition share of the budget.
2128
+ let inRangeStates = if inRangeCount > availableConcurrency {
2129
+ inRangeStates->Array.sort((a, b) => Int.compare(a.cursor, b.cursor))
2130
+ inRangeStates->Array.slice(~start=0, ~end=availableConcurrency)
2131
+ } else {
2132
+ inRangeStates
1764
2133
  }
1765
2134
 
1766
- // Water-fill. Range membership is fixed by chainTargetBlock/queryEndBlock,
1767
- // so the in-range partitions are filtered once up front; each round pours
1768
- // the remaining fresh budget at the waterLevel of the still-not-filled
1769
- // partitions' footprints and keeps only those still in range for the next
1770
- // round. Allotments sum to exactly the poured budget, so the outcome is
1771
- // order-independent and never exceeds it — the only overshoot left is the
1772
- // min-one-chunk quantization in emitQueries, bounded by one chunk per
1773
- // partition per round.
1774
- //
1775
- // No explicit round cap: a partition survives a round only by advancing
1776
- // chunksUsedThisCall (capped at maxPendingChunksPerPartition) or by
1777
- // consuming fresh budget (every emit consumes at least 1), so the
1778
- // not-filled set drains on its own and the loop also stops once the whole
1779
- // budget is poured.
1780
- let notFilledPartitions = ref(inRangeStates)
1781
- let remainingBudget = ref(rangeItemsTarget)
1782
- while notFilledPartitions.contents->Array.length > 0 && remainingBudget.contents > 0. {
1783
- let level = waterLevel(
1784
- ~budget=remainingBudget.contents,
1785
- ~footprints=notFilledPartitions.contents->Array.map(fs =>
1786
- reservedByPartition->Dict.getUnsafe(fs.partitionId)
1787
- ),
1788
- )
1789
- let next = []
1790
- notFilledPartitions.contents->Array.forEach(fs => {
1791
- let reserved = reservedByPartition->Dict.getUnsafe(fs.partitionId)
1792
- let budget = level -. reserved
1793
- if budget > 0. {
1794
- let consumed = emitQueries(fs, ~budget)
1795
- reservedByPartition->Dict.set(fs.partitionId, reserved +. consumed)
1796
- remainingBudget := remainingBudget.contents -. consumed
1797
- if fs->isInRange {
1798
- next->Array.push(fs)->ignore
1799
- }
1800
- }
1801
- })
1802
- notFilledPartitions := next
1803
- }
2135
+ candidates->pushForwardCandidates(
2136
+ ~inRangeStates,
2137
+ ~inRangeCount,
2138
+ ~chainTargetBlock,
2139
+ ~freshBudget,
2140
+ ~chunkItemsMultiplier,
2141
+ ~itemsTargetFloor,
2142
+ )
2143
+
2144
+ acceptCandidates(
2145
+ ~candidates,
2146
+ ~reservations,
2147
+ ~chainTargetItems,
2148
+ ~partitionIndexById,
2149
+ ~queriesByPartitionIndex,
2150
+ )
1804
2151
 
1805
- // Each partition pushed only into its own bucket (indexed by its
1806
- // idsInAscOrder position), so flattening reproduces idsInAscOrder order
1807
- // directly — no sort needed even though water-fill rounds interleave
1808
- // across partitions.
1809
2152
  let queries = queriesByPartitionIndex->Array.flat
1810
2153
 
1811
2154
  if queries->Utils.Array.isEmpty {
@@ -1900,10 +2243,10 @@ let make = (
1900
2243
  mergeBlock: None,
1901
2244
  dynamicContract: None,
1902
2245
  mutPendingQueries: [],
1903
- prevQueryRange: 0,
1904
- prevPrevQueryRange: 0,
1905
- prevRangeSize: 0,
1906
- latestBlockRangeUpdateBlock: 0,
2246
+ sourceRangeCapacity: 0,
2247
+ prevSourceRangeCapacity: 0,
2248
+ eventDensity: None,
2249
+ latestSourceRangeCapacityUpdateBlock: 0,
1907
2250
  })
1908
2251
  }
1909
2252
 
@@ -2146,6 +2489,8 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2146
2489
  ),
2147
2490
  }->updateInternal(
2148
2491
  ~optimizedPartitions,
2492
+ // Filtering the sorted buffer keeps it sorted and deduped.
2493
+ ~mutItemsSorted=true,
2149
2494
  ~mutItems=fetchState.buffer->Array.filter(item =>
2150
2495
  switch item {
2151
2496
  | Event({blockNumber})
@@ -2221,13 +2566,15 @@ let isReadyToEnterReorgThreshold = ({endBlock, blockLag, buffer, knownHeight} as
2221
2566
  buffer->Utils.Array.isEmpty
2222
2567
  }
2223
2568
 
2224
- // Lower progress percentage = further behind = higher priority. Shared by the
2225
- // batch ordering and the cross-chain fetch priority.
2569
+ // Lower progress percentage = further behind = higher priority. Progress is
2570
+ // relative to the head this chain can actually fetch, so a chain at its lagged
2571
+ // head does not look behind relative to unavailable blocks. Shared by the batch
2572
+ // ordering and the cross-chain fetch priority.
2226
2573
  let getProgressPercentage = (fetchState: t) => {
2227
2574
  switch fetchState.firstEventBlock {
2228
2575
  | None => 0.
2229
2576
  | Some(firstEventBlock) =>
2230
- let totalRange = fetchState.knownHeight - firstEventBlock
2577
+ let totalRange = fetchState.knownHeight - fetchState.blockLag - firstEventBlock
2231
2578
  if totalRange <= 0 {
2232
2579
  0.
2233
2580
  } else {