envio 3.3.0-alpha.10 → 3.3.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/licenses/CLA.md +35 -0
  2. package/licenses/EULA.md +67 -0
  3. package/licenses/LICENSE.md +45 -0
  4. package/licenses/README.md +35 -0
  5. package/package.json +9 -8
  6. package/src/Batch.res.mjs +1 -1
  7. package/src/ChainFetching.res +13 -10
  8. package/src/ChainFetching.res.mjs +3 -4
  9. package/src/ChainState.res +45 -30
  10. package/src/ChainState.res.mjs +22 -8
  11. package/src/ChainState.resi +5 -0
  12. package/src/Config.res +9 -5
  13. package/src/Config.res.mjs +2 -2
  14. package/src/Core.res +20 -0
  15. package/src/Core.res.mjs +12 -0
  16. package/src/CrossChainState.res +18 -1
  17. package/src/CrossChainState.res.mjs +2 -1
  18. package/src/EventConfigBuilder.res +21 -46
  19. package/src/EventConfigBuilder.res.mjs +18 -25
  20. package/src/EventProcessing.res +8 -5
  21. package/src/EventProcessing.res.mjs +2 -1
  22. package/src/FetchState.res +344 -263
  23. package/src/FetchState.res.mjs +209 -131
  24. package/src/HandlerRegister.res +3 -1
  25. package/src/HandlerRegister.res.mjs +3 -2
  26. package/src/Internal.res +11 -2
  27. package/src/LogSelection.res +0 -62
  28. package/src/LogSelection.res.mjs +0 -74
  29. package/src/RawEvent.res +2 -2
  30. package/src/SimulateDeadInputTracker.res +1 -1
  31. package/src/SimulateItems.res +38 -6
  32. package/src/SimulateItems.res.mjs +28 -9
  33. package/src/TestIndexer.res +2 -2
  34. package/src/TestIndexer.res.mjs +2 -2
  35. package/src/TopicFilter.res +1 -25
  36. package/src/TopicFilter.res.mjs +0 -74
  37. package/src/bindings/Viem.res +0 -5
  38. package/src/sources/EventRouter.res +0 -21
  39. package/src/sources/EventRouter.res.mjs +0 -12
  40. package/src/sources/EvmChain.res +2 -27
  41. package/src/sources/EvmChain.res.mjs +2 -23
  42. package/src/sources/EvmRpcClient.res +12 -15
  43. package/src/sources/EvmRpcClient.res.mjs +3 -3
  44. package/src/sources/HyperSync.res +9 -38
  45. package/src/sources/HyperSync.res.mjs +16 -28
  46. package/src/sources/HyperSync.resi +2 -2
  47. package/src/sources/HyperSyncClient.res +88 -11
  48. package/src/sources/HyperSyncClient.res.mjs +39 -6
  49. package/src/sources/HyperSyncSource.res +18 -199
  50. package/src/sources/HyperSyncSource.res.mjs +9 -128
  51. package/src/sources/Rpc.res +0 -32
  52. package/src/sources/Rpc.res.mjs +1 -46
  53. package/src/sources/RpcSource.res +29 -175
  54. package/src/sources/RpcSource.res.mjs +32 -110
  55. package/src/sources/SimulateSource.res +37 -19
  56. package/src/sources/SimulateSource.res.mjs +27 -10
  57. package/src/sources/SvmHyperSyncSource.res +3 -3
@@ -629,13 +629,90 @@ let bufferReadyCount = (fetchState: t) => {
629
629
  /*
630
630
  Comparitor for two events from the same chain. No need for chain id or timestamp
631
631
  */
632
- let compareBufferItem = (a: Internal.item, b: Internal.item) => {
633
- let blockOrdering = Int.compare(a->Internal.getItemBlockNumber, b->Internal.getItemBlockNumber)
634
- if blockOrdering === Ordering.equal {
635
- Int.compare(a->Internal.getItemLogIndex, b->Internal.getItemLogIndex)
632
+ let getRegistrationIndex = (item: Internal.item): int =>
633
+ switch item {
634
+ | Event({onEventRegistration}) => onEventRegistration.index
635
+ | Block({onBlockRegistration}) => onBlockRegistration.index
636
+ }
637
+
638
+ // Total order on buffer items: block, then logIndex, then registration index.
639
+ // Returns a plain int (-1/0/1) with explicit field comparisons so it can be
640
+ // called directly from the merge/insertion loops below — no Array.sort callback,
641
+ // no allocated key. `0` means a true duplicate: same log routed to the same
642
+ // registration (two registrations for one log differ by index and are kept).
643
+ let compareBufferItem = (a: Internal.item, b: Internal.item): int => {
644
+ let ba = a->Internal.getItemBlockNumber
645
+ let bb = b->Internal.getItemBlockNumber
646
+ if ba != bb {
647
+ ba < bb ? -1 : 1
636
648
  } else {
637
- blockOrdering
649
+ let la = a->Internal.getItemLogIndex
650
+ let lb = b->Internal.getItemLogIndex
651
+ if la != lb {
652
+ la < lb ? -1 : 1
653
+ } else {
654
+ let ia = a->getRegistrationIndex
655
+ let ib = b->getRegistrationIndex
656
+ ia < ib ? -1 : ia > ib ? 1 : 0
657
+ }
658
+ }
659
+ }
660
+
661
+ // Merge a maybe-unsorted `newItems` run into the already-sorted, already-deduped
662
+ // `buffer`, dropping items equal on (blockNumber, logIndex, registration index).
663
+ // Single linear pass over both runs after ordering `newItems` in place; every
664
+ // comparison is a direct `compareBufferItem` call (V8 inlines it) rather than a
665
+ // callback through `Array.sort`.
666
+ let mergeIntoBuffer = (buffer: array<Internal.item>, newItems: array<Internal.item>): array<
667
+ Internal.item,
668
+ > => {
669
+ let n = newItems->Array.length
670
+ // Insertion sort: a source response is small and usually already ascending,
671
+ // so this is ~O(n) here.
672
+ for i in 1 to n - 1 {
673
+ let x = newItems->Array.getUnsafe(i)
674
+ let j = ref(i - 1)
675
+ while j.contents >= 0 && compareBufferItem(newItems->Array.getUnsafe(j.contents), x) > 0 {
676
+ newItems->Array.setUnsafe(j.contents + 1, newItems->Array.getUnsafe(j.contents))
677
+ j := j.contents - 1
678
+ }
679
+ newItems->Array.setUnsafe(j.contents + 1, x)
680
+ }
681
+
682
+ let m = buffer->Array.length
683
+ let merged = []
684
+ let last = ref(None)
685
+ let push = item =>
686
+ switch last.contents {
687
+ | Some(l) if compareBufferItem(l, item) === 0 => ()
688
+ | _ => {
689
+ merged->Array.push(item)
690
+ last := Some(item)
691
+ }
692
+ }
693
+
694
+ let i = ref(0)
695
+ let j = ref(0)
696
+ while i.contents < m && j.contents < n {
697
+ let a = buffer->Array.getUnsafe(i.contents)
698
+ let b = newItems->Array.getUnsafe(j.contents)
699
+ if compareBufferItem(a, b) <= 0 {
700
+ push(a)
701
+ i := i.contents + 1
702
+ } else {
703
+ push(b)
704
+ j := j.contents + 1
705
+ }
706
+ }
707
+ while i.contents < m {
708
+ push(buffer->Array.getUnsafe(i.contents))
709
+ i := i.contents + 1
638
710
  }
711
+ while j.contents < n {
712
+ push(newItems->Array.getUnsafe(j.contents))
713
+ j := j.contents + 1
714
+ }
715
+ merged
639
716
  }
640
717
 
641
718
  // Some big number which should be bigger than any log index
@@ -706,48 +783,46 @@ let updateInternal = (
706
783
  fetchState: t,
707
784
  ~optimizedPartitions=fetchState.optimizedPartitions,
708
785
  ~mutItems=?,
786
+ // Set when the caller already passes a sorted, deduped buffer (hot paths merge
787
+ // via mergeIntoBuffer or filter the sorted buffer). Otherwise mutItems is
788
+ // normalized here, so callers can hand over items in any order.
789
+ ~mutItemsSorted=false,
709
790
  ~blockLag=fetchState.blockLag,
710
791
  ~knownHeight=fetchState.knownHeight,
711
792
  ): t => {
712
- let mutItemsRef = ref(mutItems)
793
+ // The buffer to build on: the caller's items (normalized to sorted if needed),
794
+ // or the current buffer when only onBlock items change.
795
+ let base = switch mutItems {
796
+ | Some(items) => mutItemsSorted ? items : []->mergeIntoBuffer(items)
797
+ | None => fetchState.buffer
798
+ }
713
799
 
800
+ // onBlock items are generated as their own ascending (block, logIndex) run and
801
+ // folded into `base` by the single merge below.
802
+ let blockItems = []
714
803
  let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
715
804
  | [] => knownHeight
716
- | onBlockRegistrations => {
717
- // Calculate the max block number we are going to create items for
718
- // Use maxOnBlockBufferSize to get the last target item in the buffer
719
- //
720
- // mutItems is not very reliable, since it might not be sorted,
721
- // but the chances for it happen are very low and not critical
722
- //
723
- // All this needed to prevent OOM when adding too many block items to the queue
724
- let maxBlockNumber = switch switch mutItemsRef.contents {
725
- | Some(mutItems) => mutItems
726
- | None => fetchState.buffer
727
- }->Array.get(fetchState.maxOnBlockBufferSize - 1) {
728
- | Some(item) => item->Internal.getItemBlockNumber
729
- | None =>
730
- switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
731
- | None => knownHeight
732
- | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
733
- }
734
- }
735
-
736
- let mutItems = switch mutItemsRef.contents {
737
- | Some(mutItems) => mutItems
738
- | None => fetchState.buffer->Array.copy
805
+ | onBlockRegistrations =>
806
+ // Calculate the max block number we are going to create items for
807
+ // Use maxOnBlockBufferSize to get the last target item in the buffer
808
+ // (sorted, so this is the highest-block item within the buffer cap).
809
+ // All this needed to prevent OOM when adding too many block items to the queue
810
+ let maxBlockNumber = switch base->Array.get(fetchState.maxOnBlockBufferSize - 1) {
811
+ | Some(item) => item->Internal.getItemBlockNumber
812
+ | None =>
813
+ switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
814
+ | None => knownHeight
815
+ | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
739
816
  }
740
- mutItemsRef := Some(mutItems)
741
-
742
- appendOnBlockItems(
743
- ~mutItems,
744
- ~onBlockRegistrations,
745
- ~indexerStartBlock=fetchState.startBlock,
746
- ~fromBlock=fetchState.latestOnBlockBlockNumber,
747
- ~maxBlockNumber,
748
- ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
749
- )
750
817
  }
818
+ appendOnBlockItems(
819
+ ~mutItems=blockItems,
820
+ ~onBlockRegistrations,
821
+ ~indexerStartBlock=fetchState.startBlock,
822
+ ~fromBlock=fetchState.latestOnBlockBlockNumber,
823
+ ~maxBlockNumber,
824
+ ~maxOnBlockBufferSize=fetchState.maxOnBlockBufferSize,
825
+ )
751
826
  }
752
827
 
753
828
  let updatedFetchState = {
@@ -762,15 +837,10 @@ let updateInternal = (
762
837
  latestOnBlockBlockNumber,
763
838
  blockLag,
764
839
  knownHeight,
765
- buffer: switch mutItemsRef.contents {
766
- // Theoretically it could be faster to asume that
767
- // the items are sorted, but there are cases
768
- // when the data source returns them unsorted
769
- | Some(mutItems) => {
770
- mutItems->Array.sort(compareBufferItem)
771
- mutItems
772
- }
773
- | None => fetchState.buffer
840
+ // Single merge point: fold any onBlock items into the sorted base buffer.
841
+ buffer: switch blockItems {
842
+ | [] => base
843
+ | blockItems => base->mergeIntoBuffer(blockItems)
774
844
  },
775
845
  firstEventBlock: fetchState.firstEventBlock,
776
846
  }
@@ -1287,6 +1357,26 @@ let registerDynamicContracts = (
1287
1357
  }
1288
1358
  }
1289
1359
 
1360
+ // Drop events an address-param filter rejects. A merged partition may
1361
+ // over-fetch a wildcard event whose indexed address param references an
1362
+ // address registered after the log's block; `clientAddressFilter` is the
1363
+ // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1364
+ let filterByClientAddress = (
1365
+ items: array<Internal.item>,
1366
+ ~indexingAddresses: IndexingAddresses.t,
1367
+ ): array<Internal.item> =>
1368
+ items->Array.filter(item =>
1369
+ switch item {
1370
+ | Internal.Event({payload, blockNumber}) as item =>
1371
+ switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1372
+ | Some(filter) =>
1373
+ filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1374
+ | None => true
1375
+ }
1376
+ | _ => true
1377
+ }
1378
+ )
1379
+
1290
1380
  /*
1291
1381
  Updates fetchState with a response for a given query.
1292
1382
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1295,26 +1385,10 @@ newItems are ordered earliest to latest (as they are returned from the worker)
1295
1385
  */
1296
1386
  let handleQueryResult = (
1297
1387
  fetchState: t,
1298
- ~indexingAddresses: IndexingAddresses.t,
1299
1388
  ~query: query,
1300
1389
  ~latestFetchedBlock: blockNumberAndTimestamp,
1301
1390
  ~newItems,
1302
1391
  ): t => {
1303
- // Drop events an address-param filter rejects. A merged partition may
1304
- // over-fetch a wildcard event whose indexed address param references an
1305
- // address registered after the log's block; `clientAddressFilter` is the
1306
- // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1307
- let newItems = newItems->Array.filter(item =>
1308
- switch item {
1309
- | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1310
- switch onEventRegistration.clientAddressFilter {
1311
- | Some(filter) =>
1312
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1313
- | None => true
1314
- }
1315
- | _ => true
1316
- }
1317
- )
1318
1392
  fetchState->updateInternal(
1319
1393
  ~optimizedPartitions=fetchState.optimizedPartitions->OptimizedPartitions.handleQueryResponse(
1320
1394
  ~query,
@@ -1322,10 +1396,14 @@ let handleQueryResult = (
1322
1396
  ~itemsCount=newItems->Array.length,
1323
1397
  ~latestFetchedBlock,
1324
1398
  ),
1399
+ // Merge the response into the sorted buffer, dropping duplicates an
1400
+ // overlapping query may re-deliver (e.g. an over-fetched log matched by two
1401
+ // partitions). Absorbs sorting too, so updateInternal doesn't re-sort.
1402
+ ~mutItemsSorted=true,
1325
1403
  ~mutItems=?{
1326
1404
  switch newItems {
1327
1405
  | [] => None
1328
- | _ => Some(fetchState.buffer->Array.concat(newItems))
1406
+ | _ => Some(fetchState.buffer->mergeIntoBuffer(newItems))
1329
1407
  }
1330
1408
  },
1331
1409
  )
@@ -1370,14 +1448,14 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1370
1448
  // Most parallel in-flight chunk queries a single partition may have at once.
1371
1449
  let maxPendingChunksPerPartition = 12
1372
1450
 
1373
- // Fills a gap range (a hole left between completed/pending chunks, e.g. from an
1374
- // out-of-order partial response) unconditionally — gaps are already-committed
1375
- // range, not subject to this tick's water-fill budget. Priced by the
1376
- // partition's trusted density when it has one; otherwise by the "available
1377
- // density" — the partition's equal-divide budget spread over its remaining
1378
- // range this tick — so a small gap reserves proportionally little instead of a
1379
- // noisy one-sample estimate. Chunks only on a trusted POSITIVE density, same
1380
- // rule as the water-fill. Returns the created queries' total itemsEst.
1451
+ // Generates candidate queries for a gap range (a hole left between
1452
+ // completed/pending chunks, e.g. from an out-of-order partial response). Gaps
1453
+ // carry the range's low fromBlock, so the acceptance pass takes them before
1454
+ // forward progress. Priced by the partition's trusted density when it has one;
1455
+ // otherwise by the "available density" — the partition's equal-divide budget
1456
+ // spread over its remaining range this tick — so a small gap reserves
1457
+ // proportionally little instead of a noisy one-sample estimate. Chunks only on
1458
+ // a trusted POSITIVE density. Returns the created queries' total itemsEst.
1381
1459
  let pushGapFillQueries = (
1382
1460
  queries: array<query>,
1383
1461
  ~partitionId: string,
@@ -1481,46 +1559,19 @@ let pushGapFillQueries = (
1481
1559
  cost.contents
1482
1560
  }
1483
1561
 
1484
- // The level every partition ends at when `budget` is poured across partitions
1485
- // already holding `footprints`: the unique L with Σ max(0, L - fᵢ) = budget.
1486
- // Partitions above L get nothing (their head start is their share); the rest
1487
- // are topped up exactly to L, so the pour equals the budget no matter how
1488
- // uneven the footprints are.
1489
- let waterLevel = (~budget: float, ~footprints: array<float>) => {
1490
- let sorted = footprints->Array.toSorted(Float.compare)
1491
- let n = sorted->Array.length
1492
- let prefix = ref(0.)
1493
- let level = ref(None)
1494
- let idx = ref(0)
1495
- while level.contents == None && idx.contents < n {
1496
- let i = idx.contents
1497
- prefix := prefix.contents +. sorted->Array.getUnsafe(i)
1498
- // The level if only the i+1 lowest footprints receive water — correct once
1499
- // it doesn't reach the next footprint up.
1500
- let candidate = (budget +. prefix.contents) /. (i + 1)->Int.toFloat
1501
- if i == n - 1 || candidate <= sorted->Array.getUnsafe(i + 1) {
1502
- level := Some(candidate)
1503
- }
1504
- idx := idx.contents + 1
1505
- }
1506
- level.contents->Option.getOr(0.)
1507
- }
1508
-
1509
- // Per-partition mutable state threaded through the water-fill rounds below.
1510
- type waterFillState = {
1562
+ // Per-partition state carried from the gap-fill/cursor walk to candidate
1563
+ // generation.
1564
+ type partitionFillState = {
1511
1565
  partitionId: string,
1512
1566
  p: partition,
1513
- mutable cursor: int,
1514
- mutable chunksUsedThisCall: int,
1515
- // Existing mutPendingQueries count before this call — fixed for the call,
1516
- // used with chunksUsedThisCall against maxPendingChunksPerPartition.
1567
+ cursor: int,
1568
+ // Chunks already generated for this partition during gap-fill — used with
1569
+ // pendingCount against maxPendingChunksPerPartition.
1570
+ chunksUsedThisCall: int,
1571
+ // Existing mutPendingQueries count before this call — fixed for the call.
1517
1572
  pendingCount: int,
1518
1573
  queryEndBlock: option<int>,
1519
1574
  maybeChunkRange: option<int>,
1520
- // This partition's own slot in queriesByPartitionIndex — gap-fill and every
1521
- // water-fill round push here directly, so query order falls out of
1522
- // idsInAscOrder for free (see getNextQuery) instead of a final sort pass.
1523
- bucket: array<query>,
1524
1575
  }
1525
1576
 
1526
1577
  // Candidate queries are sized against chainTargetBlock, the soft querying
@@ -1531,24 +1582,24 @@ type waterFillState = {
1531
1582
  // query with no other ceiling: the true hard bounds stay
1532
1583
  // endBlock/mergeBlock/the lagged head.
1533
1584
  //
1534
- // In-range partitions share chainTargetItems (this chain's target total
1535
- // footprintin-flight plus new) by water-fill: each round pours exactly the
1536
- // remaining fresh budget at the level computed by waterLevel, so a partition
1537
- // already holding more than the level (from earlier ticks' in-flight queries)
1538
- // gets nothing and its implicit share flows to the others, while totals stay
1539
- // even and the pour never exceeds the budget. Rounds repeat only because
1540
- // emits are quantized: a partition may consume less than its allotment (range
1541
- // or chunk-cap runs out) or slightly more (min one chunk), and the leftover
1542
- // re-pours over whoever still has range left.
1585
+ // The tick's budget is chainTargetItems minus what's already in flight. Every
1586
+ // candidate query gap-fill holes, plus each in-range partition's chunks or
1587
+ // open-ended probe toward the target is generated with no budget check, then
1588
+ // the candidates are sorted by fromBlock and accepted in that order while the
1589
+ // budget stays positive. The query that tips it negative is still accepted (a
1590
+ // single overshoot); everything after it waits for a tick with more budget.
1591
+ // Sorting by fromBlock spends the budget on the earliest blocks across all
1592
+ // partitions first, so no partition is starved by generation order and the
1593
+ // frontier advances evenly. In-flight reservations release as responses land,
1594
+ // so acceptance redistributes across ticks.
1543
1595
  //
1544
1596
  // A partition with a trusted positive density (two or more responses — see
1545
- // getMinHistoryRange) always emits at least one full-size chunk/query once
1546
- // given an allotment, sized by its real density may overshoot the
1547
- // allotment by at most one chunk (the server also enforces itemsTarget via a
1548
- // maxNumLogs-style cap, so an overshoot truncates the response rather than
1549
- // the buffer). Any other partition (no signal, one noisy sample, or a
1550
- // density-0 estimate) emits one open-ended probe sized exactly at its
1551
- // allotment instead.
1597
+ // getMinHistoryRange) generates real, density-sized chunks toward the target.
1598
+ // Any other partition (no signal, one noisy sample, or a density-0 estimate)
1599
+ // generates one open-ended probe sized to the events its range to the target is
1600
+ // expected to hold rangeTargetDensity × (chainTargetBlock fromBlock + 1) /
1601
+ // inRangeCount so several unknown-density partitions probe in parallel within
1602
+ // one budget, each scaled by how much of the range it still has to cover.
1552
1603
  let getNextQuery = (
1553
1604
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1554
1605
  ~chainTargetBlock: int,
@@ -1588,8 +1639,8 @@ let getNextQuery = (
1588
1639
  }
1589
1640
  }
1590
1641
 
1591
- // One bucket per partition, in idsInAscOrder order — gap-fill and
1592
- // water-fill both push into a partition's own bucket, so flattening at
1642
+ // One bucket per partition, in idsInAscOrder order — gap-fill and the
1643
+ // budget pass both push into a partition's own bucket, so flattening at
1593
1644
  // the end (see below) reproduces idsInAscOrder without a sort.
1594
1645
  let queriesByPartitionIndex: array<
1595
1646
  array<query>,
@@ -1607,36 +1658,50 @@ let getNextQuery = (
1607
1658
  }
1608
1659
  }
1609
1660
 
1610
- // Each partition's existing in-flight itemsEst, summed once and reused
1611
- // both for chainReserved (the call-wide fresh-budget ceiling below) and to
1612
- // seed each partition's water-fill footprint so a partition already
1613
- // holding in-flight queries is counted toward its even share and doesn't
1614
- // get topped up past the others.
1615
- let existingReservedByPartition = Dict.make()
1661
+ // In-flight itemsEst summed over queries still being fetched
1662
+ // (fetchedBlock === None). A query whose response already landed has had its
1663
+ // reservation released by ChainState even while it lingers in
1664
+ // mutPendingQueries behind an unfilled gap, so counting it here would
1665
+ // understate the budget. Sizes fresh forward work below.
1616
1666
  let chainReserved = ref(0.)
1617
- for idx in 0 to partitionsCount - 1 {
1618
- let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1619
- let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1620
- let cost = ref(0.)
1621
- for pqIdx in 0 to p.mutPendingQueries->Array.length - 1 {
1622
- cost := cost.contents +. (p.mutPendingQueries->Array.getUnsafe(pqIdx)).itemsEst->Int.toFloat
1623
- }
1624
- existingReservedByPartition->Dict.set(partitionId, cost.contents)
1625
- chainReserved := chainReserved.contents +. cost.contents
1626
- }
1627
1667
 
1628
- // Phase A: gap-fill. Walk each partition's pending queries once,
1629
- // unconditionally filling any hole (e.g. from an out-of-order partial
1630
- // chunk response) uncapped, same as before this tick's water-fill was
1631
- // introduced. This also determines each partition's post-gap cursor and
1668
+ // (fromBlock, itemsEst) of each still-in-flight query. The acceptance pass
1669
+ // merges these into the candidate stream and draws them down in fromBlock
1670
+ // order, so a gap-fill sitting before an in-flight query claims budget ahead
1671
+ // of it and the buffer unblocks without waiting for that query to return.
1672
+ let reservations = []
1673
+
1674
+ // Position of each partition in idsInAscOrder, so an accepted query routes
1675
+ // back to its bucket and the output stays in idsInAscOrder. Filled in the
1676
+ // Phase A sweep below.
1677
+ let partitionIndexById = Dict.make()
1678
+
1679
+ // Every candidate query for this tick — gap-fill holes (Phase A) plus each
1680
+ // in-range partition's chunks/probe toward the target (Phase B) — generated
1681
+ // with no budget check, then merged with the in-flight reservations, sorted
1682
+ // by fromBlock, and accepted while the budget lasts (acceptance pass).
1683
+ // Selecting by fromBlock spends the budget on the earliest blocks across all
1684
+ // partitions first, so no partition is starved by iteration order and the
1685
+ // frontier advances evenly.
1686
+ let candidates = []
1687
+
1688
+ // Phase A: gap-fill. Walk each partition's pending queries once, generating
1689
+ // a candidate for any hole (e.g. from an out-of-order partial chunk
1690
+ // response). This also determines each partition's post-gap cursor and
1632
1691
  // whether it's blocked on an unresolved single-shot query.
1633
1692
  let fillStates = []
1634
- let gapFillCost = ref(0.)
1635
1693
  for idx in 0 to partitionsCount - 1 {
1636
1694
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1637
1695
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1638
- let bucket = queriesByPartitionIndex->Array.getUnsafe(idx)
1696
+ partitionIndexById->Dict.set(partitionId, idx)
1639
1697
  let pendingCount = p.mutPendingQueries->Array.length
1698
+ for pqIdx in 0 to pendingCount - 1 {
1699
+ let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx)
1700
+ if pq.fetchedBlock === None {
1701
+ chainReserved := chainReserved.contents +. pq.itemsEst->Int.toFloat
1702
+ reservations->Array.push((pq.fromBlock, pq.itemsEst))->ignore
1703
+ }
1704
+ }
1640
1705
  let queryEndBlock = computeQueryEndBlock(p)
1641
1706
  let maybeChunkRange = getMinHistoryRange(p)
1642
1707
 
@@ -1648,9 +1713,9 @@ let getNextQuery = (
1648
1713
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1649
1714
 
1650
1715
  if pq.fromBlock > cursor.contents {
1651
- let beforeLen = bucket->Array.length
1652
- let cost = pushGapFillQueries(
1653
- bucket,
1716
+ let beforeLen = candidates->Array.length
1717
+ pushGapFillQueries(
1718
+ candidates,
1654
1719
  ~partitionId,
1655
1720
  ~rangeFromBlock=cursor.contents,
1656
1721
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
@@ -1663,9 +1728,8 @@ let getNextQuery = (
1663
1728
  ~chunkItemsMultiplier,
1664
1729
  ~selection=p.selection,
1665
1730
  ~addressesByContractName=p.addressesByContractName,
1666
- )
1667
- chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1668
- gapFillCost := gapFillCost.contents +. cost
1731
+ )->ignore
1732
+ chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
1669
1733
  }
1670
1734
  switch pq {
1671
1735
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1687,35 +1751,17 @@ let getNextQuery = (
1687
1751
  pendingCount,
1688
1752
  queryEndBlock,
1689
1753
  maybeChunkRange,
1690
- bucket,
1691
1754
  })
1692
1755
  ->ignore
1693
1756
  }
1694
1757
  }
1695
1758
 
1696
- // Fresh budget for this call what's left of chainTargetItems after
1697
- // existing in-flight queries and this tick's gap-fill. The water-fill
1698
- // rounds below hand it out; a partition already holding a large share
1699
- // (seeded below) gets proportionally less so totals stay even.
1700
- let rangeItemsTarget = Pervasives.max(
1701
- 0.,
1702
- chainTargetItems -. chainReserved.contents -. gapFillCost.contents,
1703
- )
1704
-
1705
- // Each in-range partition's running footprint: existing in-flight plus any
1706
- // gap-fill just pushed into its bucket. The water-fill levels every
1707
- // partition up toward a shared line, so this seed is what a partition
1708
- // starts the fill already holding.
1709
- let reservedByPartition = Dict.make()
1710
- fillStates->Array.forEach(fs => {
1711
- let cost = ref(existingReservedByPartition->Dict.getUnsafe(fs.partitionId))
1712
- for i in 0 to fs.bucket->Array.length - 1 {
1713
- cost := cost.contents +. (fs.bucket->Array.getUnsafe(i)).itemsEst->Int.toFloat
1714
- }
1715
- reservedByPartition->Dict.set(fs.partitionId, cost.contents)
1716
- })
1759
+ // Budget for fresh forward work: chainTargetItems minus what's still in
1760
+ // flight. Sizes probes and bounds chunk generation below; the acceptance
1761
+ // pass does the final budgeting against the full chainTargetItems.
1762
+ let freshBudget = Pervasives.max(0., chainTargetItems -. chainReserved.contents)
1717
1763
 
1718
- let isInRange = (fs: waterFillState) =>
1764
+ let isInRange = (fs: partitionFillState) =>
1719
1765
  fs.cursor <= chainTargetBlock &&
1720
1766
  switch fs.queryEndBlock {
1721
1767
  | Some(eb) => fs.cursor <= eb
@@ -1724,16 +1770,33 @@ let getNextQuery = (
1724
1770
  fs.pendingCount + fs.chunksUsedThisCall < maxPendingChunksPerPartition
1725
1771
 
1726
1772
  let inRangeStates = fillStates->Array.filter(isInRange)
1727
-
1728
- // Emits this round's queries for one partition, given its water-fill
1729
- // allotment. Mutates fs.cursor/chunksUsedThisCall and returns the
1730
- // itemsEst consumed.
1773
+ let inRangeCount = inRangeStates->Array.length
1774
+ // Even share of the fresh budget across the partitions actually fetching
1775
+ // this tick (not every partition — so budget isn't stranded on ones below
1776
+ // the head, waiting, or already done). The fallback when there's no range to
1777
+ // the target.
1778
+ let probeShare = inRangeCount == 0 ? 0. : freshBudget /. inRangeCount->Int.toFloat
1779
+ // Items/block the budget implies over the range those partitions cover this
1780
+ // tick — from the furthest-behind in-range cursor to the target. A probe
1781
+ // covering less of that range (its partition sits further ahead) gets
1782
+ // proportionally fewer items; one starting at the frontier gets the full
1783
+ // even share.
1784
+ let frontierCursor =
1785
+ inRangeStates->Array.reduce(chainTargetBlock, (min, fs) => fs.cursor < min ? fs.cursor : min)
1786
+ let rangeToTarget = chainTargetBlock - frontierCursor + 1
1787
+ let rangeTargetDensity =
1788
+ inRangeCount > 0 && rangeToTarget > 0 ? freshBudget /. rangeToTarget->Int.toFloat : 0.
1789
+
1790
+ // Phase B: generate each in-range partition's candidates — strict chunks
1791
+ // toward the target sized by real density (up to the pending-chunk cap), or,
1792
+ // for a partition without a trusted positive density, a single open-ended
1793
+ // probe at its even share of the budget. No budget check here; the
1794
+ // acceptance pass below decides which candidates make the cut.
1731
1795
  //
1732
- // Chunks require a POSITIVE trusted density: density 0 prices every chunk
1733
- // at ~nothing, letting a partition flood its full chunk pipeline with
1734
- // hard-bounded queries that crawl 1.8× per two responses — an open-ended
1735
- // probe instead gets the server's full scan range in one response.
1736
- let emitQueries = (fs: waterFillState, ~budget: float) => {
1796
+ // Chunks require a POSITIVE trusted density: density 0 prices every chunk at
1797
+ // ~nothing, so an open-ended probe (full server scan range in one response)
1798
+ // beats a pipeline of hard-bounded chunks that crawl 1.8× per two responses.
1799
+ inRangeStates->Array.forEach(fs => {
1737
1800
  let p = fs.p
1738
1801
  let maxBlock = switch fs.queryEndBlock {
1739
1802
  | Some(eb) => eb
@@ -1741,23 +1804,25 @@ let getNextQuery = (
1741
1804
  }
1742
1805
  switch (fs.maybeChunkRange, p->getTrustedDensity) {
1743
1806
  | (Some(minHistoryRange), Some(density)) if density > 0. =>
1744
- // Chunking active: strict chunks with a hard endBlock, sized by real
1745
- // density with the chunk headroom multiplier — at least one full
1746
- // chunk this round even if the allotment falls short.
1747
1807
  let chunkSize = Js.Math.ceil_int(minHistoryRange->Int.toFloat *. 1.8)
1748
1808
  let maxChunksRemaining =
1749
1809
  maxPendingChunksPerPartition - fs.pendingCount - fs.chunksUsedThisCall
1750
- let consumed = ref(0.)
1751
- let created = ref(0)
1752
- let chunkFromBlock = ref(fs.cursor)
1753
- let budgetLeft = ref(true)
1754
1810
  // No chunk starts past chainTargetBlock; an emitted chunk still keeps
1755
1811
  // its full span (chunkToBlock may exceed the target — only
1756
1812
  // endBlock/mergeBlock are hard bounds).
1813
+ let chunkStartCeiling = Pervasives.min(maxBlock, chainTargetBlock)
1814
+ let created = ref(0)
1815
+ let chunkFromBlock = ref(fs.cursor)
1816
+ // Stop once this partition alone has generated more than the whole fresh
1817
+ // budget: the acceptance pass can hand a single partition at most the
1818
+ // budget plus one overshoot query, so further chunks could never be
1819
+ // accepted. Bounds generation (and the candidate sort) when the budget
1820
+ // is small relative to the pending-chunk cap.
1821
+ let generatedItems = ref(0.)
1757
1822
  while (
1758
- budgetLeft.contents &&
1759
1823
  created.contents < maxChunksRemaining &&
1760
- chunkFromBlock.contents <= Pervasives.min(maxBlock, chainTargetBlock)
1824
+ chunkFromBlock.contents <= chunkStartCeiling &&
1825
+ generatedItems.contents <= freshBudget
1761
1826
  ) {
1762
1827
  let chunkToBlock = Pervasives.min(chunkFromBlock.contents + chunkSize - 1, maxBlock)
1763
1828
  let rawEst = density *. (chunkToBlock - chunkFromBlock.contents + 1)->Int.toFloat
@@ -1766,89 +1831,103 @@ let getNextQuery = (
1766
1831
  1,
1767
1832
  (rawEst *. chunkItemsMultiplier)->Math.ceil->Float.toInt,
1768
1833
  )
1769
- if consumed.contents == 0. || consumed.contents +. itemsEst->Int.toFloat <= budget {
1770
- fs.bucket->Array.push({
1771
- partitionId: fs.partitionId,
1772
- fromBlock: chunkFromBlock.contents,
1773
- toBlock: Some(chunkToBlock),
1774
- isChunk: true,
1775
- selection: p.selection,
1776
- itemsTarget,
1777
- itemsEst,
1778
- addressesByContractName: p.addressesByContractName,
1779
- })
1780
- consumed := consumed.contents +. itemsEst->Int.toFloat
1781
- chunkFromBlock := chunkToBlock + 1
1782
- created := created.contents + 1
1783
- } else {
1784
- budgetLeft := false
1785
- }
1834
+ candidates
1835
+ ->Array.push({
1836
+ partitionId: fs.partitionId,
1837
+ fromBlock: chunkFromBlock.contents,
1838
+ toBlock: Some(chunkToBlock),
1839
+ isChunk: true,
1840
+ selection: p.selection,
1841
+ itemsTarget,
1842
+ itemsEst,
1843
+ addressesByContractName: p.addressesByContractName,
1844
+ })
1845
+ ->ignore
1846
+ generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
1847
+ chunkFromBlock := chunkToBlock + 1
1848
+ created := created.contents + 1
1786
1849
  }
1787
- fs.cursor = chunkFromBlock.contents
1788
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + created.contents
1789
- consumed.contents
1790
1850
  | _ =>
1791
- let itemsTarget = Pervasives.max(1, Math.round(budget)->Float.toInt)
1792
- let itemsEst = itemsTarget
1793
- fs.bucket->Array.push({
1851
+ // Size the probe by the events its range to the target is expected to
1852
+ // hold rangeTargetDensity × (chainTargetBlock − fromBlock + 1), split
1853
+ // across the partitions fetching this tick. With no range to the target
1854
+ // fall back to an even share of the fresh budget, so cold chains and
1855
+ // caught-up partitions still probe.
1856
+ let itemsTarget = if rangeToTarget > 0 {
1857
+ Pervasives.max(
1858
+ 1,
1859
+ Math.round(
1860
+ rangeTargetDensity *.
1861
+ (chainTargetBlock - fs.cursor + 1)->Int.toFloat /.
1862
+ inRangeCount->Int.toFloat,
1863
+ )->Float.toInt,
1864
+ )
1865
+ } else {
1866
+ Pervasives.max(1, Math.round(probeShare)->Float.toInt)
1867
+ }
1868
+ candidates
1869
+ ->Array.push({
1794
1870
  partitionId: fs.partitionId,
1795
1871
  fromBlock: fs.cursor,
1796
1872
  toBlock: fs.queryEndBlock,
1797
1873
  isChunk: false,
1798
1874
  selection: p.selection,
1799
1875
  itemsTarget,
1800
- itemsEst,
1876
+ itemsEst: itemsTarget,
1801
1877
  addressesByContractName: p.addressesByContractName,
1802
1878
  })
1803
- fs.cursor = maxBlock + 1
1804
- fs.chunksUsedThisCall = fs.chunksUsedThisCall + 1
1805
- itemsEst->Int.toFloat
1879
+ ->ignore
1806
1880
  }
1807
- }
1881
+ })
1808
1882
 
1809
- // Water-fill. Range membership is fixed by chainTargetBlock/queryEndBlock,
1810
- // so the in-range partitions are filtered once up front; each round pours
1811
- // the remaining fresh budget at the waterLevel of the still-not-filled
1812
- // partitions' footprints and keeps only those still in range for the next
1813
- // round. Allotments sum to exactly the poured budget, so the outcome is
1814
- // order-independent and never exceeds it the only overshoot left is the
1815
- // min-one-chunk quantization in emitQueries, bounded by one chunk per
1816
- // partition per round.
1817
- //
1818
- // No explicit round cap: every emit advances chunksUsedThisCall (capped at
1819
- // maxPendingChunksPerPartition) and consumes a positive amount of fresh
1820
- // budget, so the not-filled set drains on its own and the loop also stops
1821
- // once the whole budget is poured.
1822
- let notFilledPartitions = ref(inRangeStates)
1823
- let remainingBudget = ref(rangeItemsTarget)
1824
- while notFilledPartitions.contents->Array.length > 0 && remainingBudget.contents > 0. {
1825
- let level = waterLevel(
1826
- ~budget=remainingBudget.contents,
1827
- ~footprints=notFilledPartitions.contents->Array.map(fs =>
1828
- reservedByPartition->Dict.getUnsafe(fs.partitionId)
1829
- ),
1830
- )
1831
- let next = []
1832
- notFilledPartitions.contents->Array.forEach(fs => {
1833
- let reserved = reservedByPartition->Dict.getUnsafe(fs.partitionId)
1834
- let budget = level -. reserved
1835
- if budget > 0. {
1836
- let consumed = emitQueries(fs, ~budget)
1837
- reservedByPartition->Dict.set(fs.partitionId, reserved +. consumed)
1838
- remainingBudget := remainingBudget.contents -. consumed
1839
-
1840
- if fs->isInRange {
1841
- next->Array.push(fs)->ignore
1842
- }
1883
+ // Acceptance: merge fresh candidates (Some) with the in-flight reservations
1884
+ // (None) and walk them in fromBlock order, starting from the full
1885
+ // chainTargetItems. A reservation just draws down the budget its query is
1886
+ // already sent while a candidate draws down the budget and is emitted.
1887
+ // Because a gap-fill's fromBlock precedes the in-flight query it unblocks,
1888
+ // it claims budget ahead of that reservation, so the buffer never deadlocks
1889
+ // waiting on a hole it can't fund. The candidate that tips the budget
1890
+ // negative is still emitted (a single overshoot); everything after it waits
1891
+ // for a tick with more budget. Accepted queries route back to their
1892
+ // partition bucket, so the output stays in idsInAscOrder with each
1893
+ // partition's queries in fromBlock order.
1894
+ let acceptanceStream = []
1895
+ candidates->Array.forEach(query =>
1896
+ acceptanceStream->Array.push((query.fromBlock, query.itemsEst, Some(query)))->ignore
1897
+ )
1898
+ reservations->Array.forEach(((fromBlock, itemsEst)) =>
1899
+ acceptanceStream->Array.push((fromBlock, itemsEst, None))->ignore
1900
+ )
1901
+ // Sort by fromBlock; on a tie charge the in-flight reservation (None) before
1902
+ // a fresh candidate (Some), so a same-block candidate can't overshoot the
1903
+ // target buffer. Only a strictly-earlier candidate — a gap-fill, whose
1904
+ // fromBlock precedes the query it unblocks — borrows ahead of a reservation.
1905
+ acceptanceStream->Array.sort(((aFrom, _, aQuery), (bFrom, _, bQuery)) =>
1906
+ if aFrom !== bFrom {
1907
+ Int.compare(aFrom, bFrom)
1908
+ } else {
1909
+ switch (aQuery, bQuery) {
1910
+ | (None, Some(_)) => Ordering.less
1911
+ | (Some(_), None) => Ordering.greater
1912
+ | (None, None) | (Some(_), Some(_)) => Ordering.equal
1843
1913
  }
1844
- })
1845
- notFilledPartitions := next
1914
+ }
1915
+ )
1916
+ let streamCount = acceptanceStream->Array.length
1917
+ let remainingBudget = ref(chainTargetItems)
1918
+ let acceptIdx = ref(0)
1919
+ while remainingBudget.contents > 0. && acceptIdx.contents < streamCount {
1920
+ let (_, itemsEst, maybeQuery) = acceptanceStream->Array.getUnsafe(acceptIdx.contents)
1921
+ switch maybeQuery {
1922
+ | Some(query) =>
1923
+ let partitionIdx = partitionIndexById->Dict.getUnsafe(query.partitionId)
1924
+ queriesByPartitionIndex->Array.getUnsafe(partitionIdx)->Array.push(query)->ignore
1925
+ | None => ()
1926
+ }
1927
+ remainingBudget := remainingBudget.contents -. itemsEst->Int.toFloat
1928
+ acceptIdx := acceptIdx.contents + 1
1846
1929
  }
1847
1930
 
1848
- // Each partition pushed only into its own bucket (indexed by its
1849
- // idsInAscOrder position), so flattening reproduces idsInAscOrder order
1850
- // directly — no sort needed even though water-fill rounds interleave
1851
- // across partitions.
1852
1931
  let queries = queriesByPartitionIndex->Array.flat
1853
1932
 
1854
1933
  if queries->Utils.Array.isEmpty {
@@ -2189,6 +2268,8 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2189
2268
  ),
2190
2269
  }->updateInternal(
2191
2270
  ~optimizedPartitions,
2271
+ // Filtering the sorted buffer keeps it sorted and deduped.
2272
+ ~mutItemsSorted=true,
2192
2273
  ~mutItems=fetchState.buffer->Array.filter(item =>
2193
2274
  switch item {
2194
2275
  | Event({blockNumber})