envio 3.3.0-alpha.8 → 3.3.0-rc.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 (107) 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 +205 -59
  13. package/src/ChainState.res.mjs +130 -27
  14. package/src/ChainState.resi +16 -1
  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 +147 -39
  20. package/src/CrossChainState.res.mjs +42 -10
  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/EnvioGlobal.res +53 -0
  27. package/src/EnvioGlobal.res.mjs +31 -0
  28. package/src/EventConfigBuilder.res +30 -56
  29. package/src/EventConfigBuilder.res.mjs +24 -30
  30. package/src/EventProcessing.res +8 -5
  31. package/src/EventProcessing.res.mjs +2 -1
  32. package/src/FetchState.res +842 -474
  33. package/src/FetchState.res.mjs +545 -356
  34. package/src/HandlerLoader.res +1 -1
  35. package/src/HandlerLoader.res.mjs +1 -1
  36. package/src/HandlerRegister.res +478 -305
  37. package/src/HandlerRegister.res.mjs +277 -209
  38. package/src/HandlerRegister.resi +11 -6
  39. package/src/InMemoryStore.res +14 -26
  40. package/src/InMemoryStore.res.mjs +6 -19
  41. package/src/IndexerState.res +15 -39
  42. package/src/IndexerState.res.mjs +18 -29
  43. package/src/IndexerState.resi +2 -10
  44. package/src/Internal.res +121 -18
  45. package/src/Internal.res.mjs +96 -2
  46. package/src/LoadLayer.res +44 -24
  47. package/src/LoadLayer.res.mjs +43 -20
  48. package/src/LoadLayer.resi +1 -0
  49. package/src/LogSelection.res +92 -217
  50. package/src/LogSelection.res.mjs +95 -184
  51. package/src/Main.res +29 -143
  52. package/src/Main.res.mjs +25 -88
  53. package/src/Metrics.res +1 -1
  54. package/src/Metrics.res.mjs +1 -1
  55. package/src/Persistence.res +12 -3
  56. package/src/PgStorage.res +155 -82
  57. package/src/PgStorage.res.mjs +130 -77
  58. package/src/Prometheus.res +20 -10
  59. package/src/Prometheus.res.mjs +22 -10
  60. package/src/PruneStaleHistory.res +146 -35
  61. package/src/PruneStaleHistory.res.mjs +114 -20
  62. package/src/RawEvent.res +2 -2
  63. package/src/Rollback.res +32 -13
  64. package/src/Rollback.res.mjs +24 -11
  65. package/src/RollbackCommit.res +4 -1
  66. package/src/RollbackCommit.res.mjs +3 -2
  67. package/src/SimulateDeadInputTracker.res +1 -1
  68. package/src/SimulateItems.res +39 -7
  69. package/src/SimulateItems.res.mjs +28 -9
  70. package/src/TestIndexer.res +2 -2
  71. package/src/TestIndexer.res.mjs +2 -2
  72. package/src/TopicFilter.res +1 -25
  73. package/src/TopicFilter.res.mjs +0 -74
  74. package/src/UserContext.res +62 -23
  75. package/src/UserContext.res.mjs +26 -6
  76. package/src/Writing.res +60 -21
  77. package/src/Writing.res.mjs +27 -9
  78. package/src/bindings/NodeJs.res +5 -0
  79. package/src/bindings/Viem.res +0 -5
  80. package/src/sources/EventRouter.res +0 -21
  81. package/src/sources/EventRouter.res.mjs +0 -12
  82. package/src/sources/Evm.res +4 -1
  83. package/src/sources/Evm.res.mjs +1 -1
  84. package/src/sources/EvmChain.res +2 -27
  85. package/src/sources/EvmChain.res.mjs +2 -23
  86. package/src/sources/EvmRpcClient.res +58 -23
  87. package/src/sources/EvmRpcClient.res.mjs +11 -5
  88. package/src/sources/Fuel.res +3 -1
  89. package/src/sources/Fuel.res.mjs +1 -1
  90. package/src/sources/HyperSync.res +9 -38
  91. package/src/sources/HyperSync.res.mjs +16 -28
  92. package/src/sources/HyperSync.resi +2 -2
  93. package/src/sources/HyperSyncClient.res +88 -11
  94. package/src/sources/HyperSyncClient.res.mjs +39 -6
  95. package/src/sources/HyperSyncSource.res +18 -210
  96. package/src/sources/HyperSyncSource.res.mjs +9 -137
  97. package/src/sources/Rpc.res +0 -32
  98. package/src/sources/Rpc.res.mjs +1 -46
  99. package/src/sources/RpcSource.res +129 -533
  100. package/src/sources/RpcSource.res.mjs +161 -379
  101. package/src/sources/SimulateSource.res +37 -19
  102. package/src/sources/SimulateSource.res.mjs +27 -10
  103. package/src/sources/SourceManager.res +4 -14
  104. package/src/sources/SourceManager.res.mjs +3 -9
  105. package/src/sources/SourceManager.resi +0 -2
  106. package/src/sources/SvmHyperSyncSource.res +13 -3
  107. 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.
22
- itemsTarget: float,
19
+ // Items this in-flight query is targeting (server maxNumLogs-style cap).
20
+ itemsTarget: int,
21
+ // Estimated items this in-flight query will actually return (no headroom),
22
+ // carried from the query so the shared buffer budget can account for what's
23
+ // already being fetched without the cap's safety margin inflating it.
24
+ itemsEst: int,
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.
71
- itemsTarget: float,
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.
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
  }
@@ -91,45 +103,41 @@ let deriveContractNameByAddress: dict<array<Address.t>> => dict<
91
103
  result
92
104
  })
93
105
 
94
- // Floor for a query's itemsTarget. Also used by SourceManager as the floor for
95
- // its own maxNumLogs-style safety net.
96
- let minItemsTarget = 2_000.
97
-
98
- // Ceiling for an unknown-density probe query's itemsTarget. A partition with no
99
- // trusted density yet gets one open-ended probe; without a cap it would claim its
100
- // full even share of the chain's (possibly whole-pool) budget and starve sibling
101
- // chains needing their own first probe in the same cross-chain tick.
102
- let maxItemsTarget = 10_000.
103
-
104
- // itemsTarget for a query over [fromBlock, toBlock], from the partition's event
105
- // density (items/block derived from its last response). toBlock None is the
106
- // open-ended tail, capped at chainTargetBlock — the soft per-tick horizon the
107
- // owning chain wants to reach (see getNextQuery). A partition that responded
108
- // with no items has density 0, so its queries cost 0 — correct, they don't
109
- // fill the buffer. Only called for partitions with a known density
110
- // (prevQueryRange > 0); partitions with no signal yet are sized instead
111
- // against their even split of the chain's range budget.
112
- let densityItemsTarget = (p: partition, ~fromBlock, ~toBlock, ~chainTargetBlock) => {
113
- let density = p.prevRangeSize->Int.toFloat /. p.prevQueryRange->Int.toFloat
114
- (toBlock->Option.getOr(chainTargetBlock) - fromBlock + 1)->Int.toFloat *. density
106
+ // itemsTarget for a query over [fromBlock, toBlock] at the given event density
107
+ // (items/block). toBlock None is the open-ended tail, capped at
108
+ // chainTargetBlock the soft per-tick horizon the owning chain wants to reach
109
+ // (see getNextQuery).
110
+ let densityItemsTarget = (~density, ~fromBlock, ~toBlock, ~chainTargetBlock) => {
111
+ // Floor at 1: the reservation must equal the server-side cap SourceManager
112
+ // sends, and a 0 cap would ask the backend for nothing.
113
+ Pervasives.max(
114
+ 1,
115
+ ((toBlock->Option.getOr(chainTargetBlock) - fromBlock + 1)->Int.toFloat *. density)
116
+ ->Math.ceil
117
+ ->Float.toInt,
118
+ )
115
119
  }
116
120
 
117
- // 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.
118
122
  let getMinHistoryRange = (p: partition) => {
119
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
123
+ switch (p.sourceRangeCapacity, p.prevSourceRangeCapacity) {
120
124
  | (0, _) | (_, 0) => None
121
125
  | (a, b) => Some(a < b ? a : b)
122
126
  }
123
127
  }
124
128
 
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
132
+
125
133
  let getMinQueryRange = (partitions: array<partition>) => {
126
134
  let min = ref(0)
127
135
  for i in 0 to partitions->Array.length - 1 {
128
136
  let p = partitions->Array.getUnsafe(i)
129
137
 
130
138
  // Even if it's fetching, set dynamicContract field
131
- let a = p.prevQueryRange
132
- let b = p.prevPrevQueryRange
139
+ let a = p.sourceRangeCapacity
140
+ let b = p.prevSourceRangeCapacity
133
141
  if a > 0 && (min.contents == 0 || a < min.contents) {
134
142
  min := a
135
143
  }
@@ -201,6 +209,15 @@ module OptimizedPartitions = {
201
209
  let newId = nextPartitionIndexRef.contents->Int.toString
202
210
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
203
211
  let minRange = getMinQueryRange([p1, p2])
212
+ // The merged partition indexes both parents' addresses, so its expected
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
+ }
204
221
  {
205
222
  id: newId,
206
223
  dynamicContract: Some(contractName),
@@ -209,10 +226,10 @@ module OptimizedPartitions = {
209
226
  mergeBlock: None,
210
227
  addressesByContractName: Dict.make(), // set below
211
228
  mutPendingQueries: [],
212
- prevQueryRange: minRange,
213
- prevPrevQueryRange: minRange,
214
- prevRangeSize: 0,
215
- latestBlockRangeUpdateBlock: 0,
229
+ sourceRangeCapacity: minRange,
230
+ prevSourceRangeCapacity: minRange,
231
+ eventDensity: inheritedDensity,
232
+ latestSourceRangeCapacityUpdateBlock: 0,
216
233
  }
217
234
  }
218
235
 
@@ -413,14 +430,19 @@ module OptimizedPartitions = {
413
430
  ) => {
414
431
  let latestFetchedBlock = ref(initialLatestFetchedBlock)
415
432
 
416
- while (
417
- mutPendingQueries->Array.length > 0 && {
418
- let pq = mutPendingQueries->Utils.Array.firstUnsafe
419
- pq.fetchedBlock !== None && pq.fromBlock <= latestFetchedBlock.contents.blockNumber + 1
420
- }
421
- ) {
422
- let removedQuery = mutPendingQueries->Array.shift->Option.getUnsafe
423
- 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
424
446
  }
425
447
 
426
448
  latestFetchedBlock.contents
@@ -460,28 +482,51 @@ module OptimizedPartitions = {
460
482
  pendingQuery.fetchedBlock = Some(latestFetchedBlock)
461
483
 
462
484
  let blockRange = latestFetchedBlock.blockNumber - query.fromBlock + 1
463
- // 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.
464
498
  // Prevents degradation of the chunking heuristic when parallel query
465
499
  // responses arrive out of order (e.g. earlier query with smaller range
466
500
  // arriving after a later query with bigger range).
467
- let shouldUpdateBlockRange =
468
- latestFetchedBlock.blockNumber > p.latestBlockRangeUpdateBlock &&
501
+ let shouldUpdateSourceRangeCapacity =
502
+ latestFetchedBlock.blockNumber > p.latestSourceRangeCapacityUpdateBlock &&
469
503
  switch query.toBlock {
470
- | 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
471
507
  | Some(queryToBlock) =>
472
- // Update on partial response (direct capacity evidence),
473
- // or when the query's intended range covers at least the partition's
474
- // current chunk range meaning it was a capacity-based split chunk,
475
- // not a small gap-fill whose toBlock is an artificial boundary.
476
- 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.
477
518
  switch getMinHistoryRange(p) {
478
519
  | None => false // Chunking not active yet, don't update
479
520
  | Some(minHistoryRange) => queryToBlock - query.fromBlock + 1 >= minHistoryRange
480
521
  }
522
+ }
481
523
  }
482
- let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
483
- let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
484
- 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
485
530
 
486
531
  // Process fetched queries from front of queue for main partition
487
532
  let updatedLatestFetchedBlock = consumeFetchedQueries(
@@ -497,28 +542,65 @@ module OptimizedPartitions = {
497
542
 
498
543
  if partitionReachedMergeBlock {
499
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
+ )
500
553
  } else {
501
554
  let updatedMainPartition = {
502
555
  ...p,
503
556
  latestFetchedBlock: updatedLatestFetchedBlock,
504
- prevQueryRange: updatedPrevQueryRange,
505
- prevPrevQueryRange: updatedPrevPrevQueryRange,
506
- prevRangeSize: updatedPrevRangeSize,
507
- latestBlockRangeUpdateBlock: shouldUpdateBlockRange
557
+ sourceRangeCapacity: updatedSourceRangeCapacity,
558
+ prevSourceRangeCapacity: updatedPrevSourceRangeCapacity,
559
+ eventDensity: updatedEventDensity,
560
+ latestSourceRangeCapacityUpdateBlock: shouldUpdateSourceRangeCapacity
508
561
  ? latestFetchedBlock.blockNumber
509
- : p.latestBlockRangeUpdateBlock,
562
+ : p.latestSourceRangeCapacityUpdateBlock,
510
563
  }
511
564
 
512
565
  mutEntities->Dict.set(p.id, updatedMainPartition)
513
- }
514
566
 
515
- // Re-optimize to maintain sorted order and apply optimizations
516
- make(
517
- ~partitions=mutEntities->Dict.valuesToArray,
518
- ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
519
- ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
520
- ~dynamicContracts=optimizedPartitions.dynamicContracts,
521
- )
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
+ }
522
604
  }
523
605
 
524
606
  @inline
@@ -611,13 +693,90 @@ let bufferReadyCount = (fetchState: t) => {
611
693
  /*
612
694
  Comparitor for two events from the same chain. No need for chain id or timestamp
613
695
  */
614
- let compareBufferItem = (a: Internal.item, b: Internal.item) => {
615
- let blockOrdering = Int.compare(a->Internal.getItemBlockNumber, b->Internal.getItemBlockNumber)
616
- if blockOrdering === Ordering.equal {
617
- 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
618
712
  } else {
619
- 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
774
+ }
775
+ while j.contents < n {
776
+ push(newItems->Array.getUnsafe(j.contents))
777
+ j := j.contents + 1
620
778
  }
779
+ merged
621
780
  }
622
781
 
623
782
  // Some big number which should be bigger than any log index
@@ -688,48 +847,46 @@ let updateInternal = (
688
847
  fetchState: t,
689
848
  ~optimizedPartitions=fetchState.optimizedPartitions,
690
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,
691
854
  ~blockLag=fetchState.blockLag,
692
855
  ~knownHeight=fetchState.knownHeight,
693
856
  ): t => {
694
- 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
+ }
695
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 = []
696
867
  let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
697
868
  | [] => knownHeight
698
- | onBlockRegistrations => {
699
- // Calculate the max block number we are going to create items for
700
- // Use maxOnBlockBufferSize to get the last target item in the buffer
701
- //
702
- // mutItems is not very reliable, since it might not be sorted,
703
- // but the chances for it happen are very low and not critical
704
- //
705
- // All this needed to prevent OOM when adding too many block items to the queue
706
- let maxBlockNumber = switch switch mutItemsRef.contents {
707
- | Some(mutItems) => mutItems
708
- | None => fetchState.buffer
709
- }->Array.get(fetchState.maxOnBlockBufferSize - 1) {
710
- | Some(item) => item->Internal.getItemBlockNumber
711
- | None =>
712
- switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
713
- | None => knownHeight
714
- | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
715
- }
716
- }
717
-
718
- let mutItems = switch mutItemsRef.contents {
719
- | Some(mutItems) => mutItems
720
- | 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
721
880
  }
722
- mutItemsRef := Some(mutItems)
723
-
724
- appendOnBlockItems(
725
- ~mutItems,
726
- ~onBlockRegistrations,
727
- ~indexerStartBlock=fetchState.startBlock,
728
- ~fromBlock=fetchState.latestOnBlockBlockNumber,
729
- ~maxBlockNumber,
730
- ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
731
- )
732
881
  }
882
+ appendOnBlockItems(
883
+ ~mutItems=blockItems,
884
+ ~onBlockRegistrations,
885
+ ~indexerStartBlock=fetchState.startBlock,
886
+ ~fromBlock=fetchState.latestOnBlockBlockNumber,
887
+ ~maxBlockNumber,
888
+ ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
889
+ )
733
890
  }
734
891
 
735
892
  let updatedFetchState = {
@@ -744,15 +901,10 @@ let updateInternal = (
744
901
  latestOnBlockBlockNumber,
745
902
  blockLag,
746
903
  knownHeight,
747
- buffer: switch mutItemsRef.contents {
748
- // Theoretically it could be faster to asume that
749
- // the items are sorted, but there are cases
750
- // when the data source returns them unsorted
751
- | Some(mutItems) => {
752
- mutItems->Array.sort(compareBufferItem)
753
- mutItems
754
- }
755
- | 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)
756
908
  },
757
909
  firstEventBlock: fetchState.firstEventBlock,
758
910
  }
@@ -898,10 +1050,10 @@ OptimizedPartitions.t => {
898
1050
  addressesByContractName,
899
1051
  mergeBlock: None,
900
1052
  mutPendingQueries: [],
901
- prevQueryRange: 0,
902
- prevPrevQueryRange: 0,
903
- prevRangeSize: 0,
904
- latestBlockRangeUpdateBlock: 0,
1053
+ sourceRangeCapacity: 0,
1054
+ prevSourceRangeCapacity: 0,
1055
+ eventDensity: None,
1056
+ latestSourceRangeCapacityUpdateBlock: 0,
905
1057
  })
906
1058
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
907
1059
  }
@@ -1234,10 +1386,10 @@ let registerDynamicContracts = (
1234
1386
  addressesByContractName,
1235
1387
  mergeBlock: None,
1236
1388
  mutPendingQueries: p.mutPendingQueries,
1237
- prevQueryRange: p.prevQueryRange,
1238
- prevPrevQueryRange: p.prevPrevQueryRange,
1239
- prevRangeSize: p.prevRangeSize,
1240
- latestBlockRangeUpdateBlock: p.latestBlockRangeUpdateBlock,
1389
+ sourceRangeCapacity: p.sourceRangeCapacity,
1390
+ prevSourceRangeCapacity: p.prevSourceRangeCapacity,
1391
+ eventDensity: p.eventDensity,
1392
+ latestSourceRangeCapacityUpdateBlock: p.latestSourceRangeCapacityUpdateBlock,
1241
1393
  })
1242
1394
  }
1243
1395
  }
@@ -1269,6 +1421,26 @@ let registerDynamicContracts = (
1269
1421
  }
1270
1422
  }
1271
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
+
1272
1444
  /*
1273
1445
  Updates fetchState with a response for a given query.
1274
1446
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1277,26 +1449,10 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1277
1449
  */
1278
1450
  let handleQueryResult = (
1279
1451
  fetchState: t,
1280
- ~indexingAddresses: IndexingAddresses.t,
1281
1452
  ~query: query,
1282
1453
  ~latestFetchedBlock: blockNumberAndTimestamp,
1283
1454
  ~newItems,
1284
1455
  ): t => {
1285
- // Drop events an address-param filter rejects. A merged partition may
1286
- // over-fetch a wildcard event whose indexed address param references an
1287
- // address registered after the log's block; `clientAddressFilter` is the
1288
- // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1289
- let newItems = newItems->Array.filter(item =>
1290
- switch item {
1291
- | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1292
- switch onEventRegistration.clientAddressFilter {
1293
- | Some(filter) =>
1294
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1295
- | None => true
1296
- }
1297
- | _ => true
1298
- }
1299
- )
1300
1456
  fetchState->updateInternal(
1301
1457
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1302
1458
  ~query,
@@ -1304,10 +1460,14 @@ let handleQueryResult = (
1304
1460
  ~itemsCount=newItems->Array.length,
1305
1461
  ~latestFetchedBlock,
1306
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,
1307
1467
  ~mutItems=?{
1308
1468
  switch newItems {
1309
1469
  | [] => None
1310
- | _ => Some(fetchState.buffer->Array.concat(newItems))
1470
+ | _ => Some(fetchState.buffer->mergeIntoBuffer(newItems))
1311
1471
  }
1312
1472
  },
1313
1473
  )
@@ -1328,6 +1488,7 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1328
1488
  toBlock: q.toBlock,
1329
1489
  isChunk: q.isChunk,
1330
1490
  itemsTarget: q.itemsTarget,
1491
+ itemsEst: q.itemsEst,
1331
1492
  fetchedBlock: None,
1332
1493
  }
1333
1494
 
@@ -1349,54 +1510,129 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1349
1510
  }
1350
1511
 
1351
1512
  // Most parallel in-flight chunk queries a single partition may have at once.
1352
- let maxPendingChunksPerPartition = 10
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
+ }
1353
1578
 
1354
- // Fills a gap range (a hole left between completed/pending chunks, e.g. from an
1355
- // out-of-order partial response) unconditionally against the partition's real
1356
- // density gaps are already-committed range, not subject to this tick's
1357
- // water-fill budget. Returns the created queries' total itemsTarget.
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.
1358
1590
  let pushGapFillQueries = (
1359
1591
  queries: array<query>,
1360
1592
  ~partitionId: string,
1361
1593
  ~rangeFromBlock: int,
1362
1594
  ~rangeEndBlock: option<int>,
1363
- ~knownHeight: int,
1595
+ ~headBlockNumber: int,
1364
1596
  ~chainTargetBlock: int,
1365
1597
  ~maybeChunkRange: option<int>,
1366
1598
  ~maxChunks: int,
1367
1599
  ~partition: partition,
1600
+ ~partitionBudget: float,
1601
+ ~chunkItemsMultiplier: float,
1602
+ ~itemsTargetFloor: int,
1368
1603
  ~selection: selection,
1369
1604
  ~addressesByContractName: dict<array<Address.t>>,
1370
1605
  ) => {
1371
- let cost = ref(0.)
1372
- 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 {
1373
1611
  switch rangeEndBlock {
1374
1612
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1375
1613
  | _ =>
1376
- switch maybeChunkRange {
1377
- | None =>
1378
- let itemsTarget = densityItemsTarget(
1379
- partition,
1614
+ let maxBlock = switch rangeEndBlock {
1615
+ | Some(eb) => eb
1616
+ | None => chainTargetBlock
1617
+ }
1618
+ let pushSingleQuery = (~density, ~isChunk) =>
1619
+ queries
1620
+ ->pushDensityPricedQuery(
1621
+ ~partitionId,
1380
1622
  ~fromBlock=rangeFromBlock,
1381
1623
  ~toBlock=rangeEndBlock,
1624
+ ~isChunk,
1625
+ ~density,
1626
+ ~chunkItemsMultiplier,
1382
1627
  ~chainTargetBlock,
1628
+ ~itemsTargetFloor,
1629
+ ~selection,
1630
+ ~addressesByContractName,
1383
1631
  )
1384
- queries->Array.push({
1385
- partitionId,
1386
- fromBlock: rangeFromBlock,
1387
- toBlock: rangeEndBlock,
1388
- selection,
1389
- isChunk: false,
1390
- itemsTarget,
1391
- addressesByContractName,
1392
- })
1393
- cost := cost.contents +. itemsTarget
1394
- | Some(chunkRange) =>
1395
- let maxBlock = switch rangeEndBlock {
1396
- | Some(eb) => eb
1397
- | None => chainTargetBlock
1398
- }
1399
- let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 1.8)
1632
+ ->ignore
1633
+ switch (partition->getTrustedDensity, maybeChunkRange) {
1634
+ | (Some(density), Some(chunkRange)) if density > 0. =>
1635
+ let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. chunkRangeGrowthFactor)
1400
1636
  if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
1401
1637
  let chunkFromBlock = ref(rangeFromBlock)
1402
1638
  let chunkIdx = ref(0)
@@ -1404,65 +1640,304 @@ let pushGapFillQueries = (
1404
1640
  chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1405
1641
  ) {
1406
1642
  let chunkToBlock = chunkFromBlock.contents + chunkSize - 1
1407
- let itemsTarget = densityItemsTarget(
1408
- partition,
1643
+ queries
1644
+ ->pushDensityPricedQuery(
1645
+ ~partitionId,
1409
1646
  ~fromBlock=chunkFromBlock.contents,
1410
1647
  ~toBlock=Some(chunkToBlock),
1648
+ ~isChunk=true,
1649
+ ~density,
1650
+ ~chunkItemsMultiplier,
1411
1651
  ~chainTargetBlock,
1652
+ ~itemsTargetFloor,
1653
+ ~selection,
1654
+ ~addressesByContractName,
1412
1655
  )
1413
- queries->Array.push({
1414
- partitionId,
1415
- fromBlock: chunkFromBlock.contents,
1416
- toBlock: Some(chunkToBlock),
1417
- isChunk: true,
1418
- selection,
1419
- itemsTarget,
1420
- addressesByContractName,
1421
- })
1422
- cost := cost.contents +. itemsTarget
1656
+ ->ignore
1423
1657
  chunkFromBlock := chunkToBlock + 1
1424
1658
  chunkIdx := chunkIdx.contents + 1
1425
1659
  }
1426
1660
  } else {
1427
1661
  // Not enough room for 2 chunks, fall back to a single query
1428
- let itemsTarget = densityItemsTarget(
1429
- partition,
1430
- ~fromBlock=rangeFromBlock,
1431
- ~toBlock=rangeEndBlock,
1432
- ~chainTargetBlock,
1433
- )
1434
- queries->Array.push({
1435
- partitionId,
1436
- fromBlock: rangeFromBlock,
1437
- toBlock: rangeEndBlock,
1438
- selection,
1439
- isChunk: rangeEndBlock !== None,
1440
- itemsTarget,
1441
- addressesByContractName,
1442
- })
1443
- cost := cost.contents +. itemsTarget
1662
+ pushSingleQuery(~density, ~isChunk=rangeEndBlock !== None)
1444
1663
  }
1664
+ | (Some(density), None) if density > 0. => pushSingleQuery(~density, ~isChunk=false)
1665
+ | _ =>
1666
+ let remainingRange = Pervasives.max(1, chainTargetBlock - rangeFromBlock + 1)
1667
+ pushSingleQuery(~density=partitionBudget /. remainingRange->Int.toFloat, ~isChunk=false)
1445
1668
  }
1446
1669
  }
1447
1670
  }
1448
- cost.contents
1449
1671
  }
1450
1672
 
1451
- // Per-partition mutable state threaded through the water-fill rounds below.
1452
- type waterFillState = {
1673
+ // Per-partition state carried from the gap-fill/cursor walk to candidate
1674
+ // generation.
1675
+ type partitionFillState = {
1453
1676
  partitionId: string,
1454
1677
  p: partition,
1455
- mutable cursor: int,
1456
- mutable chunksUsedThisCall: int,
1457
- // Existing mutPendingQueries count before this call — fixed for the call,
1458
- // used with chunksUsedThisCall against maxPendingChunksPerPartition.
1459
- 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,
1460
1685
  queryEndBlock: option<int>,
1461
1686
  maybeChunkRange: option<int>,
1462
- // This partition's own slot in queriesByPartitionIndex — gap-fill and every
1463
- // water-fill round push here directly, so query order falls out of
1464
- // idsInAscOrder for free (see getNextQuery) instead of a final sort pass.
1465
- 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
+ }
1466
1941
  }
1467
1942
 
1468
1943
  // Candidate queries are sized against chainTargetBlock, the soft querying
@@ -1473,25 +1948,33 @@ type waterFillState = {
1473
1948
  // query with no other ceiling: the true hard bounds stay
1474
1949
  // endBlock/mergeBlock/the lagged head.
1475
1950
  //
1476
- // In-range partitions evenly split chainTargetItems (this chain's target
1477
- // total footprint in-flight plus new). A partition already holding more
1478
- // than its even share (from earlier ticks' in-flight queries) is skipped this
1479
- // round so its share flows to the others; the split is recomputed every round
1480
- // against the shrinking set of partitions still with range left, so leftover
1481
- // keeps redistributing until the budget or the range runs out.
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.
1482
1963
  //
1483
- // A partition with a trusted density (two or more responses — see
1484
- // getMinHistoryRange) always emits at least one full-size chunk/query once
1485
- // included in a round, sized by its real density uncapped, may overshoot
1486
- // that round's share (the server also enforces itemsTarget via a
1487
- // maxNumLogs-style cap, so an overshoot truncates the response rather than
1488
- // the buffer). A partition with no trusted density yet (zero or one
1489
- // response — a single sample is too noisy to size by) emits one query sized
1490
- // exactly to its round's share 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.
1491
1970
  let getNextQuery = (
1492
1971
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1493
1972
  ~chainTargetBlock: int,
1494
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,
1495
1978
  ) => {
1496
1979
  let headBlockNumber = knownHeight - blockLag
1497
1980
  if headBlockNumber <= 0 {
@@ -1508,12 +1991,40 @@ let getNextQuery = (
1508
1991
 
1509
1992
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1510
1993
 
1511
- // Every partition is visited once here regardless of whether it gets a
1512
- // query pushed below — waiting-for-new-block bookkeeping shouldn't depend
1513
- // 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()
1514
2014
  for idx in 0 to partitionsCount - 1 {
1515
2015
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1516
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)
1517
2028
  if (
1518
2029
  p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1519
2030
  ) {
@@ -1526,8 +2037,20 @@ let getNextQuery = (
1526
2037
  }
1527
2038
  }
1528
2039
 
1529
- // One bucket per partition, in idsInAscOrder order gap-fill and
1530
- // 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
1531
2054
  // the end (see below) reproduces idsInAscOrder without a sort.
1532
2055
  let queriesByPartitionIndex: array<
1533
2056
  array<query>,
@@ -1545,246 +2068,87 @@ let getNextQuery = (
1545
2068
  }
1546
2069
  }
1547
2070
 
1548
- // Each partition's existing in-flight itemsTarget, summed once and reused
1549
- // both for chainReserved (the call-wide fresh-budget ceiling below) and to
1550
- // seed each partition's water-fill footprint so a partition already
1551
- // holding in-flight queries is counted toward its even share and doesn't
1552
- // get topped up past the others.
1553
- let existingReservedByPartition = Dict.make()
1554
- let chainReserved = ref(0.)
1555
- for idx in 0 to partitionsCount - 1 {
1556
- let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1557
- let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1558
- let cost = ref(0.)
1559
- for pqIdx in 0 to p.mutPendingQueries->Array.length - 1 {
1560
- cost := cost.contents +. (p.mutPendingQueries->Array.getUnsafe(pqIdx)).itemsTarget
1561
- }
1562
- existingReservedByPartition->Dict.set(partitionId, cost.contents)
1563
- chainReserved := chainReserved.contents +. cost.contents
1564
- }
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
1565
2084
 
1566
- // Phase A: gap-fill. Walk each partition's pending queries once,
1567
- // unconditionally filling any hole (e.g. from an out-of-order partial
1568
- // chunk response) — uncapped, same as before this tick's water-fill was
1569
- // introduced. This also determines each partition's post-gap cursor and
1570
- // whether it's blocked on an unresolved single-shot query.
1571
2085
  let fillStates = []
1572
- let gapFillCost = ref(0.)
1573
2086
  for idx in 0 to partitionsCount - 1 {
1574
2087
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1575
2088
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1576
- let bucket = queriesByPartitionIndex->Array.getUnsafe(idx)
1577
- let pendingCount = p.mutPendingQueries->Array.length
1578
- let queryEndBlock = computeQueryEndBlock(p)
1579
- let maybeChunkRange = getMinHistoryRange(p)
1580
-
1581
- let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1582
- let canContinue = ref(true)
1583
- let chunksUsedThisCall = ref(0)
1584
- let pqIdx = ref(0)
1585
- while pqIdx.contents < pendingCount && canContinue.contents {
1586
- let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1587
-
1588
- if pq.fromBlock > cursor.contents {
1589
- let beforeLen = bucket->Array.length
1590
- let cost = pushGapFillQueries(
1591
- bucket,
1592
- ~partitionId,
1593
- ~rangeFromBlock=cursor.contents,
1594
- ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1595
- ~knownHeight,
1596
- ~chainTargetBlock,
1597
- ~maybeChunkRange,
1598
- ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1599
- ~partition=p,
1600
- ~selection=p.selection,
1601
- ~addressesByContractName=p.addressesByContractName,
1602
- )
1603
- chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1604
- gapFillCost := gapFillCost.contents +. cost
1605
- }
1606
- switch pq {
1607
- | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
1608
- if blockNumber < toBlock =>
1609
- cursor := blockNumber + 1
1610
- | {isChunk: true, toBlock: Some(toBlock)} => cursor := toBlock + 1
1611
- | _ => canContinue := false
1612
- }
1613
- pqIdx := pqIdx.contents + 1
1614
- }
1615
-
1616
- if canContinue.contents {
1617
- fillStates
1618
- ->Array.push({
1619
- partitionId,
1620
- p,
1621
- cursor: cursor.contents,
1622
- chunksUsedThisCall: chunksUsedThisCall.contents,
1623
- pendingCount,
1624
- queryEndBlock,
1625
- maybeChunkRange,
1626
- bucket,
1627
- })
1628
- ->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 => ()
1629
2102
  }
1630
2103
  }
1631
2104
 
1632
- // Fresh budget for this call what's left of chainTargetItems after
1633
- // existing in-flight queries and this tick's gap-fill. The water-fill
1634
- // rounds below hand it out; a partition already holding a large share
1635
- // (seeded below) gets proportionally less so totals stay even.
1636
- let rangeItemsTarget = Pervasives.max(
1637
- 0.,
1638
- chainTargetItems -. chainReserved.contents -. gapFillCost.contents,
1639
- )
1640
-
1641
- // Each in-range partition's running footprint: existing in-flight plus any
1642
- // gap-fill just pushed into its bucket. The water-fill levels every
1643
- // partition up toward a shared line, so this seed is what a partition
1644
- // starts the fill already holding.
1645
- let reservedByPartition = Dict.make()
1646
- fillStates->Array.forEach(fs => {
1647
- let cost = ref(existingReservedByPartition->Dict.getUnsafe(fs.partitionId))
1648
- for i in 0 to fs.bucket->Array.length - 1 {
1649
- cost := cost.contents +. (fs.bucket->Array.getUnsafe(i)).itemsTarget
1650
- }
1651
- reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1652
- })
1653
-
1654
- // Emits this round's queries for one partition, given its share of the
1655
- // range budget. Mutates fs.cursor/chunksUsedThisCall and returns the
1656
- // itemsTarget consumed.
1657
- //
1658
- // Density is trusted only once the chunking heuristic is active
1659
- // (maybeChunkRange requires prevQueryRange AND prevPrevQueryRange, i.e.
1660
- // two responses — see getMinHistoryRange) — a single response is noisy
1661
- // (a first query that happens to land on an unusually dense/sparse block
1662
- // would otherwise mis-size the very next one), so it's treated the same
1663
- // as a partition with no signal at all: sized exactly to this round's
1664
- // share instead of an uncapped, one-sample density estimate.
1665
- let emitQueries = (fs: waterFillState, ~budget: float) => {
1666
- let p = fs.p
1667
- let maxBlock = switch fs.queryEndBlock {
1668
- | Some(eb) => eb
1669
- | None => chainTargetBlock
1670
- }
1671
- switch fs.maybeChunkRange {
1672
- | Some(minHistoryRange) =>
1673
- // Chunking active: strict chunks with a hard endBlock, uncapped real
1674
- // density — at least one full chunk this round even if budget falls
1675
- // short.
1676
- let density = p.prevRangeSize->Int.toFloat /. p.prevQueryRange->Int.toFloat
1677
- let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1678
- let chunkCost = density *. chunkSize->Int.toFloat
1679
- let maxChunksRemaining =
1680
- maxPendingChunksPerPartition - fs.pendingCount - fs.chunksUsedThisCall
1681
- let affordable = if chunkCost > 0. {
1682
- Math.floor(budget /. chunkCost)->Float.toInt
1683
- } else {
1684
- maxChunksRemaining
1685
- }
1686
- let numChunks = Pervasives.max(1, Pervasives.min(affordable, maxChunksRemaining))
1687
- let consumed = ref(0.)
1688
- let created = ref(0)
1689
- let chunkFromBlock = ref(fs.cursor)
1690
- while created.contents < numChunks && chunkFromBlock.contents <= maxBlock {
1691
- let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1692
- let itemsTarget = density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat
1693
- fs.bucket->Array.push({
1694
- partitionId: fs.partitionId,
1695
- fromBlock: chunkFromBlock.contents,
1696
- toBlock: Some(chunkToBlock),
1697
- isChunk: true,
1698
- selection: p.selection,
1699
- itemsTarget,
1700
- addressesByContractName: p.addressesByContractName,
1701
- })
1702
- consumed := consumed.contents +. itemsTarget
1703
- chunkFromBlock := chunkToBlock + 1
1704
- created := created.contents + 1
1705
- }
1706
- fs.cursor = chunkFromBlock.contents
1707
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + created.contents
1708
- consumed.contents
1709
- | None =>
1710
- // No trusted density yet (zero or one response): one open query sized
1711
- // to this round's share, capped so a single probe can't swallow the
1712
- // whole (possibly cross-chain-wide) budget and starve other partitions
1713
- // or chains before they get their own first probe.
1714
- let itemsTarget = Pervasives.min(Math.round(budget), maxItemsTarget)
1715
- fs.bucket->Array.push({
1716
- partitionId: fs.partitionId,
1717
- fromBlock: fs.cursor,
1718
- toBlock: fs.queryEndBlock,
1719
- isChunk: false,
1720
- selection: p.selection,
1721
- itemsTarget,
1722
- addressesByContractName: p.addressesByContractName,
1723
- })
1724
- fs.cursor = maxBlock + 1
1725
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + 1
1726
- itemsTarget
1727
- }
1728
- }
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)
1729
2109
 
1730
- let isInRange = (fs: waterFillState) =>
2110
+ let isInRange = (fs: partitionFillState) =>
1731
2111
  fs.cursor <= chainTargetBlock &&
1732
2112
  switch fs.queryEndBlock {
1733
2113
  | Some(eb) => fs.cursor <= eb
1734
2114
  | None => true
1735
2115
  } &&
1736
- fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
1737
-
1738
- // Water-fill. Range membership is fixed by chainTargetBlock/queryEndBlock,
1739
- // so the in-range partitions are filtered once up front; each round levels
1740
- // every still-not-filled partition up toward a shared line and keeps only
1741
- // those still in range for the next round.
1742
- //
1743
- // The line is (remaining fresh budget + those partitions' current
1744
- // footprint) / count: a partition already holding more than the line gets
1745
- // nothing and drops out (its head start is its whole share), while the
1746
- // rest are topped up to the line so leftover from an under-using or
1747
- // filled partition redistributes to whoever can still use it, and totals
1748
- // stay even regardless of processing order (the line is fixed before the
1749
- // round's emits).
1750
- //
1751
- // No explicit round cap: a partition survives a round only by advancing
1752
- // chunksUsedThisCall (capped at maxPendingChunksPerPartition) or by
1753
- // consuming fresh budget, so the not-filled set drains on its own and the
1754
- // loop also stops once the whole budget is reserved.
1755
- let notFilledPartitions = ref(fillStates->Array.filter(isInRange))
1756
- let reservedFromRange = ref(0.)
1757
- while (
1758
- notFilledPartitions.contents->Array.length > 0 &&
1759
- reservedFromRange.contents < rangeItemsTarget
1760
- ) {
1761
- let n = notFilledPartitions.contents->Array.length
1762
- let footprintSum = ref(0.)
1763
- notFilledPartitions.contents->Array.forEach(fs =>
1764
- footprintSum := footprintSum.contents +. reservedByPartition->Dict.getUnsafe(fs.partitionId)
1765
- )
1766
- let line =
1767
- (rangeItemsTarget -. reservedFromRange.contents +. footprintSum.contents) /. n->Int.toFloat
1768
- let next = []
1769
- notFilledPartitions.contents->Array.forEach(fs => {
1770
- let reserved = reservedByPartition->Dict.getUnsafe(fs.partitionId)
1771
- let budget = line -. reserved
1772
- if budget > 0. {
1773
- let consumed = emitQueries(fs, ~budget)
1774
- reservedByPartition->Dict.set(fs.partitionId, reserved +. consumed)
1775
- reservedFromRange := reservedFromRange.contents +. consumed
1776
- if fs->isInRange {
1777
- next->Array.push(fs)->ignore
1778
- }
1779
- }
1780
- })
1781
- notFilledPartitions := next
2116
+ fs.inFlightCount + fs.chunksUsedThisCall < maxInFlightChunksPerPartition
2117
+
2118
+ let inRangeStates = fillStates->Array.filter(isInRange)
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
1782
2133
  }
1783
2134
 
1784
- // Each partition pushed only into its own bucket (indexed by its
1785
- // idsInAscOrder position), so flattening reproduces idsInAscOrder order
1786
- // directly — no sort needed even though water-fill rounds interleave
1787
- // across partitions.
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
+ )
2151
+
1788
2152
  let queries = queriesByPartitionIndex->Array.flat
1789
2153
 
1790
2154
  if queries->Utils.Array.isEmpty {
@@ -1879,10 +2243,10 @@ let make = (
1879
2243
  mergeBlock: None,
1880
2244
  dynamicContract: None,
1881
2245
  mutPendingQueries: [],
1882
- prevQueryRange: 0,
1883
- prevPrevQueryRange: 0,
1884
- prevRangeSize: 0,
1885
- latestBlockRangeUpdateBlock: 0,
2246
+ sourceRangeCapacity: 0,
2247
+ prevSourceRangeCapacity: 0,
2248
+ eventDensity: None,
2249
+ latestSourceRangeCapacityUpdateBlock: 0,
1886
2250
  })
1887
2251
  }
1888
2252
 
@@ -2125,6 +2489,8 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2125
2489
  ),
2126
2490
  }->updateInternal(
2127
2491
  ~optimizedPartitions,
2492
+ // Filtering the sorted buffer keeps it sorted and deduped.
2493
+ ~mutItemsSorted=true,
2128
2494
  ~mutItems=fetchState.buffer->Array.filter(item =>
2129
2495
  switch item {
2130
2496
  | Event({blockNumber})
@@ -2200,13 +2566,15 @@ let isReadyToEnterReorgThreshold = ({endBlock, blockLag, buffer, knownHeight} as
2200
2566
  buffer->Utils.Array.isEmpty
2201
2567
  }
2202
2568
 
2203
- // Lower progress percentage = further behind = higher priority. Shared by the
2204
- // 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.
2205
2573
  let getProgressPercentage = (fetchState: t) => {
2206
2574
  switch fetchState.firstEventBlock {
2207
2575
  | None => 0.
2208
2576
  | Some(firstEventBlock) =>
2209
- let totalRange = fetchState.knownHeight - firstEventBlock
2577
+ let totalRange = fetchState.knownHeight - fetchState.blockLag - firstEventBlock
2210
2578
  if totalRange <= 0 {
2211
2579
  0.
2212
2580
  } else {