envio 3.3.0-alpha.7 → 3.3.0-alpha.8

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 (89) hide show
  1. package/index.d.ts +23 -5
  2. package/package.json +6 -6
  3. package/src/Api.res +1 -1
  4. package/src/Api.res.mjs +1 -1
  5. package/src/ChainFetching.res +21 -10
  6. package/src/ChainFetching.res.mjs +11 -10
  7. package/src/ChainState.res +328 -165
  8. package/src/ChainState.res.mjs +205 -100
  9. package/src/ChainState.resi +16 -7
  10. package/src/Config.res +11 -27
  11. package/src/Config.res.mjs +8 -7
  12. package/src/Core.res +7 -0
  13. package/src/CrossChainState.res +53 -80
  14. package/src/CrossChainState.res.mjs +43 -59
  15. package/src/CrossChainState.resi +1 -1
  16. package/src/Ecosystem.res +3 -3
  17. package/src/Ecosystem.res.mjs +3 -3
  18. package/src/Envio.res +14 -9
  19. package/src/EventConfigBuilder.res +105 -56
  20. package/src/EventConfigBuilder.res.mjs +69 -33
  21. package/src/EventProcessing.res +19 -15
  22. package/src/EventProcessing.res.mjs +13 -12
  23. package/src/FetchState.res +375 -171
  24. package/src/FetchState.res.mjs +249 -251
  25. package/src/HandlerLoader.res +7 -112
  26. package/src/HandlerLoader.res.mjs +1 -87
  27. package/src/HandlerRegister.res +209 -6
  28. package/src/HandlerRegister.res.mjs +90 -5
  29. package/src/HandlerRegister.resi +13 -2
  30. package/src/IndexerState.res +6 -3
  31. package/src/IndexerState.res.mjs +3 -3
  32. package/src/IndexerState.resi +1 -1
  33. package/src/IndexingAddresses.res +10 -7
  34. package/src/IndexingAddresses.res.mjs +8 -7
  35. package/src/IndexingAddresses.resi +3 -1
  36. package/src/Internal.res +82 -36
  37. package/src/Internal.res.mjs +11 -1
  38. package/src/Main.res +22 -23
  39. package/src/Main.res.mjs +15 -17
  40. package/src/Metrics.res +43 -2
  41. package/src/Metrics.res.mjs +39 -3
  42. package/src/Prometheus.res +0 -35
  43. package/src/Prometheus.res.mjs +33 -68
  44. package/src/RawEvent.res +7 -2
  45. package/src/RawEvent.res.mjs +4 -4
  46. package/src/SimulateItems.res +20 -7
  47. package/src/SimulateItems.res.mjs +7 -11
  48. package/src/TestIndexer.res +1 -1
  49. package/src/TestIndexer.res.mjs +1 -1
  50. package/src/sources/BlockStore.res +46 -0
  51. package/src/sources/BlockStore.res.mjs +24 -0
  52. package/src/sources/EventRouter.res +19 -12
  53. package/src/sources/EventRouter.res.mjs +7 -5
  54. package/src/sources/Evm.res +54 -4
  55. package/src/sources/Evm.res.mjs +43 -4
  56. package/src/sources/EvmChain.res +14 -18
  57. package/src/sources/EvmChain.res.mjs +12 -13
  58. package/src/sources/FieldMask.res +39 -0
  59. package/src/sources/FieldMask.res.mjs +42 -0
  60. package/src/sources/Fuel.res +5 -2
  61. package/src/sources/Fuel.res.mjs +4 -3
  62. package/src/sources/HyperFuelSource.res +31 -40
  63. package/src/sources/HyperFuelSource.res.mjs +52 -44
  64. package/src/sources/HyperSync.res +31 -9
  65. package/src/sources/HyperSync.res.mjs +47 -31
  66. package/src/sources/HyperSync.resi +10 -3
  67. package/src/sources/HyperSyncClient.res +15 -4
  68. package/src/sources/HyperSyncHeightStream.res +1 -8
  69. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  70. package/src/sources/HyperSyncSource.res +37 -58
  71. package/src/sources/HyperSyncSource.res.mjs +42 -42
  72. package/src/sources/RpcSource.res +91 -82
  73. package/src/sources/RpcSource.res.mjs +76 -45
  74. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  75. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  76. package/src/sources/SimulateSource.res +5 -3
  77. package/src/sources/SimulateSource.res.mjs +12 -4
  78. package/src/sources/Source.res +19 -5
  79. package/src/sources/SourceManager.res +62 -6
  80. package/src/sources/SourceManager.res.mjs +55 -8
  81. package/src/sources/SourceManager.resi +10 -0
  82. package/src/sources/Svm.res +14 -10
  83. package/src/sources/Svm.res.mjs +23 -6
  84. package/src/sources/SvmHyperSyncClient.res +5 -6
  85. package/src/sources/SvmHyperSyncSource.res +82 -41
  86. package/src/sources/SvmHyperSyncSource.res.mjs +86 -41
  87. package/src/sources/TransactionStore.res +6 -107
  88. package/src/sources/TransactionStore.res.mjs +5 -86
  89. package/svm.schema.json +19 -0
@@ -7,15 +7,19 @@ type blockNumberAndTimestamp = {
7
7
 
8
8
  type blockNumberAndLogIndex = {blockNumber: int, logIndex: int}
9
9
 
10
- type selection = {eventConfigs: array<Internal.eventConfig>, dependsOnAddresses: bool}
10
+ type selection = {
11
+ onEventRegistrations: array<Internal.onEventRegistration>,
12
+ dependsOnAddresses: bool,
13
+ }
11
14
 
12
15
  type pendingQuery = {
13
16
  fromBlock: int,
14
17
  toBlock: option<int>,
15
18
  isChunk: bool,
16
- // Estimated items this in-flight query will add, carried from the query so the
17
- // shared buffer budget can account for what's already being fetched.
18
- estResponseSize: float,
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
23
  // Stores latestFetchedBlock when query completes. Only needed to persist
20
24
  // timestamp while earlier queries are still pending before updating
21
25
  // the partition's latestFetchedBlock.
@@ -59,15 +63,12 @@ type query = {
59
63
  fromBlock: int,
60
64
  toBlock: option<int>,
61
65
  isChunk: bool,
62
- // Estimated number of items the query will return, from the partition's
63
- // density and the query's block range. Used to admit queries against the
64
- // shared buffer budget.
65
- estResponseSize: float,
66
- // Owning chain and the chain progress % at the query's fromBlock. Set by the
67
- // cross-chain scheduler so candidate queries can be pooled and ordered
68
- // (furthest-behind first) without allocating a side tuple per query.
69
- mutable chainId: int,
70
- mutable progress: float,
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,
71
72
  selection: selection,
72
73
  addressesByContractName: dict<array<Address.t>>,
73
74
  }
@@ -90,42 +91,28 @@ let deriveContractNameByAddress: dict<array<Address.t>> => dict<
90
91
  result
91
92
  })
92
93
 
93
- // Bounds for the no-history default estimate below. Also used by SourceManager
94
- // as the floor for its own maxNumLogs-style safety net.
95
- let minEstResponseSize = 2_000.
96
- let maxEstResponseSize = 10_000.
97
-
98
- // Default estimate for a query whose partition hasn't responded yet, so the
99
- // shared budget still accounts for unknown queries instead of treating them as
100
- // free. Scaled down as partitionsCount grows: assuming every partition's first
101
- // query is "big" starves the rest of that tick's admission when many
102
- // partitions share the same buffer, so the per-partition default shrinks
103
- // accordingly clamped to [minEstResponseSize, maxEstResponseSize] either way.
104
- let calculateDefaultEstResponseSize = (~partitionsCount) =>
105
- Pervasives.max(
106
- minEstResponseSize,
107
- Pervasives.min(maxEstResponseSize, 20_000. /. partitionsCount->Int.toFloat),
108
- )
109
-
110
- // Estimated items a query will return, from the partition's event density
111
- // (items/block derived from its last response) and the query's block range.
112
- // toBlock None is the open-ended tail, capped at knownHeight. A partition
113
- // that responded with no items has density 0, so its queries cost 0 — correct,
114
- // they don't fill the buffer. Only a partition that has never responded
115
- // (prevQueryRange 0) has no signal, so it falls back to calculateDefaultEstResponseSize.
116
- let calculateEstResponseSize = (
117
- p: partition,
118
- ~fromBlock,
119
- ~toBlock,
120
- ~knownHeight,
121
- ~partitionsCount,
122
- ) =>
123
- if p.prevQueryRange > 0 {
124
- let density = p.prevRangeSize->Int.toFloat /. p.prevQueryRange->Int.toFloat
125
- (toBlock->Option.getOr(knownHeight) - fromBlock + 1)->Int.toFloat *. density
126
- } else {
127
- calculateDefaultEstResponseSize(~partitionsCount)
128
- }
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
115
+ }
129
116
 
130
117
  // Calculate the chunk range from history using min-of-last-3-ranges heuristic
131
118
  let getMinHistoryRange = (p: partition) => {
@@ -566,7 +553,7 @@ type t = {
566
553
  // size). Event fetch depth is bounded separately, by CrossChainState's
567
554
  // cross-chain admission against the indexer-wide buffer pool.
568
555
  maxOnBlockBufferSize: int,
569
- onBlockConfigs: array<Internal.onBlockConfig>,
556
+ onBlockRegistrations: array<Internal.onBlockRegistration>,
570
557
  knownHeight: int,
571
558
  firstEventBlock: option<int>,
572
559
  }
@@ -642,7 +629,7 @@ let blockItemLogIndex = 16777216
642
629
  // are generated at once to prevent OOM.
643
630
  let appendOnBlockItems = (
644
631
  ~mutItems: array<Internal.item>,
645
- ~onBlockConfigs: array<Internal.onBlockConfig>,
632
+ ~onBlockRegistrations: array<Internal.onBlockRegistration>,
646
633
  ~indexerStartBlock,
647
634
  ~fromBlock,
648
635
  ~maxBlockNumber,
@@ -662,27 +649,27 @@ let appendOnBlockItems = (
662
649
  let blockNumber = latestOnBlockBlockNumber.contents + 1
663
650
  latestOnBlockBlockNumber := blockNumber
664
651
 
665
- for configIdx in 0 to onBlockConfigs->Array.length - 1 {
666
- let onBlockConfig = onBlockConfigs->Array.getUnsafe(configIdx)
652
+ for configIdx in 0 to onBlockRegistrations->Array.length - 1 {
653
+ let onBlockRegistration = onBlockRegistrations->Array.getUnsafe(configIdx)
667
654
 
668
- let handlerStartBlock = switch onBlockConfig.startBlock {
655
+ let handlerStartBlock = switch onBlockRegistration.startBlock {
669
656
  | Some(startBlock) => startBlock
670
657
  | None => indexerStartBlock
671
658
  }
672
659
 
673
660
  if (
674
661
  blockNumber >= handlerStartBlock &&
675
- switch onBlockConfig.endBlock {
662
+ switch onBlockRegistration.endBlock {
676
663
  | Some(endBlock) => blockNumber <= endBlock
677
664
  | None => true
678
665
  } &&
679
- (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockConfig.interval) === 0
666
+ (blockNumber - handlerStartBlock)->Pervasives.mod(onBlockRegistration.interval) === 0
680
667
  ) {
681
668
  mutItems->Array.push(
682
669
  Block({
683
- onBlockConfig,
670
+ onBlockRegistration,
684
671
  blockNumber,
685
- logIndex: blockItemLogIndex + onBlockConfig.index,
672
+ logIndex: blockItemLogIndex + onBlockRegistration.index,
686
673
  }),
687
674
  )
688
675
  newItemsCounter := newItemsCounter.contents + 1
@@ -706,9 +693,9 @@ let updateInternal = (
706
693
  ): t => {
707
694
  let mutItemsRef = ref(mutItems)
708
695
 
709
- let latestOnBlockBlockNumber = switch fetchState.onBlockConfigs {
696
+ let latestOnBlockBlockNumber = switch fetchState.onBlockRegistrations {
710
697
  | [] => knownHeight
711
- | onBlockConfigs => {
698
+ | onBlockRegistrations => {
712
699
  // Calculate the max block number we are going to create items for
713
700
  // Use maxOnBlockBufferSize to get the last target item in the buffer
714
701
  //
@@ -736,7 +723,7 @@ let updateInternal = (
736
723
 
737
724
  appendOnBlockItems(
738
725
  ~mutItems,
739
- ~onBlockConfigs,
726
+ ~onBlockRegistrations,
740
727
  ~indexerStartBlock=fetchState.startBlock,
741
728
  ~fromBlock=fetchState.latestOnBlockBlockNumber,
742
729
  ~maxBlockNumber,
@@ -751,7 +738,7 @@ let updateInternal = (
751
738
  contractConfigs: fetchState.contractConfigs,
752
739
  normalSelection: fetchState.normalSelection,
753
740
  chainId: fetchState.chainId,
754
- onBlockConfigs: fetchState.onBlockConfigs,
741
+ onBlockRegistrations: fetchState.onBlockRegistrations,
755
742
  maxOnBlockBufferSize: fetchState.maxOnBlockBufferSize,
756
743
  optimizedPartitions,
757
744
  latestOnBlockBlockNumber,
@@ -1017,7 +1004,7 @@ let registerDynamicContracts = (
1017
1004
  // Might contain duplicates which we should filter out
1018
1005
  items: array<Internal.item>,
1019
1006
  ) => {
1020
- if fetchState.normalSelection.eventConfigs->Utils.Array.isEmpty {
1007
+ if fetchState.normalSelection.onEventRegistrations->Utils.Array.isEmpty {
1021
1008
  // Can the normalSelection be empty?
1022
1009
  JsError.throwWithMessage(
1023
1010
  "Invalid configuration. No events to fetch for the dynamic contract registration.",
@@ -1301,8 +1288,8 @@ let handleQueryResult = (
1301
1288
  // param-level analogue of EventRouter's srcAddress effectiveStartBlock check.
1302
1289
  let newItems = newItems->Array.filter(item =>
1303
1290
  switch item {
1304
- | Internal.Event({eventConfig, payload, blockNumber}) =>
1305
- switch eventConfig.clientAddressFilter {
1291
+ | Internal.Event({onEventRegistration, payload, blockNumber}) =>
1292
+ switch onEventRegistration.clientAddressFilter {
1306
1293
  | Some(filter) =>
1307
1294
  filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1308
1295
  | None => true
@@ -1340,7 +1327,7 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1340
1327
  fromBlock: q.fromBlock,
1341
1328
  toBlock: q.toBlock,
1342
1329
  isChunk: q.isChunk,
1343
- estResponseSize: q.estResponseSize,
1330
+ itemsTarget: q.itemsTarget,
1344
1331
  fetchedBlock: None,
1345
1332
  }
1346
1333
 
@@ -1364,47 +1351,50 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1364
1351
  // Most parallel in-flight chunk queries a single partition may have at once.
1365
1352
  let maxPendingChunksPerPartition = 10
1366
1353
 
1367
- @inline
1368
- let pushQueriesForRange = (
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.
1358
+ let pushGapFillQueries = (
1369
1359
  queries: array<query>,
1370
1360
  ~partitionId: string,
1371
1361
  ~rangeFromBlock: int,
1372
1362
  ~rangeEndBlock: option<int>,
1373
1363
  ~knownHeight: int,
1364
+ ~chainTargetBlock: int,
1374
1365
  ~maybeChunkRange: option<int>,
1375
1366
  ~maxChunks: int,
1376
1367
  ~partition: partition,
1377
1368
  ~selection: selection,
1378
1369
  ~addressesByContractName: dict<array<Address.t>>,
1379
- ~partitionsCount: int,
1380
1370
  ) => {
1371
+ let cost = ref(0.)
1381
1372
  if rangeFromBlock <= knownHeight && maxChunks > 0 {
1382
1373
  switch rangeEndBlock {
1383
1374
  | Some(endBlock) if rangeFromBlock > endBlock => ()
1384
1375
  | _ =>
1385
1376
  switch maybeChunkRange {
1386
1377
  | None =>
1378
+ let itemsTarget = densityItemsTarget(
1379
+ partition,
1380
+ ~fromBlock=rangeFromBlock,
1381
+ ~toBlock=rangeEndBlock,
1382
+ ~chainTargetBlock,
1383
+ )
1387
1384
  queries->Array.push({
1388
1385
  partitionId,
1389
1386
  fromBlock: rangeFromBlock,
1390
1387
  toBlock: rangeEndBlock,
1391
1388
  selection,
1392
1389
  isChunk: false,
1393
- estResponseSize: calculateEstResponseSize(
1394
- partition,
1395
- ~fromBlock=rangeFromBlock,
1396
- ~toBlock=rangeEndBlock,
1397
- ~knownHeight,
1398
- ~partitionsCount,
1399
- ),
1400
- chainId: 0,
1401
- progress: 0.,
1390
+ itemsTarget,
1402
1391
  addressesByContractName,
1403
1392
  })
1393
+ cost := cost.contents +. itemsTarget
1404
1394
  | Some(chunkRange) =>
1405
1395
  let maxBlock = switch rangeEndBlock {
1406
1396
  | Some(eb) => eb
1407
- | None => knownHeight
1397
+ | None => chainTargetBlock
1408
1398
  }
1409
1399
  let chunkSize = Js.Math.ceil_int(chunkRange->Int.toFloat *. 1.8)
1410
1400
  if rangeFromBlock + chunkSize * 2 - 1 <= maxBlock {
@@ -1414,58 +1404,94 @@ let pushQueriesForRange = (
1414
1404
  chunkIdx.contents < maxChunks && chunkFromBlock.contents + chunkSize - 1 <= maxBlock
1415
1405
  ) {
1416
1406
  let chunkToBlock = chunkFromBlock.contents + chunkSize - 1
1407
+ let itemsTarget = densityItemsTarget(
1408
+ partition,
1409
+ ~fromBlock=chunkFromBlock.contents,
1410
+ ~toBlock=Some(chunkToBlock),
1411
+ ~chainTargetBlock,
1412
+ )
1417
1413
  queries->Array.push({
1418
1414
  partitionId,
1419
1415
  fromBlock: chunkFromBlock.contents,
1420
1416
  toBlock: Some(chunkToBlock),
1421
1417
  isChunk: true,
1422
1418
  selection,
1423
- estResponseSize: calculateEstResponseSize(
1424
- partition,
1425
- ~fromBlock=chunkFromBlock.contents,
1426
- ~toBlock=Some(chunkToBlock),
1427
- ~knownHeight,
1428
- ~partitionsCount,
1429
- ),
1430
- chainId: 0,
1431
- progress: 0.,
1419
+ itemsTarget,
1432
1420
  addressesByContractName,
1433
1421
  })
1422
+ cost := cost.contents +. itemsTarget
1434
1423
  chunkFromBlock := chunkToBlock + 1
1435
1424
  chunkIdx := chunkIdx.contents + 1
1436
1425
  }
1437
1426
  } else {
1438
1427
  // 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
+ )
1439
1434
  queries->Array.push({
1440
1435
  partitionId,
1441
1436
  fromBlock: rangeFromBlock,
1442
1437
  toBlock: rangeEndBlock,
1443
1438
  selection,
1444
1439
  isChunk: rangeEndBlock !== None,
1445
- estResponseSize: calculateEstResponseSize(
1446
- partition,
1447
- ~fromBlock=rangeFromBlock,
1448
- ~toBlock=rangeEndBlock,
1449
- ~knownHeight,
1450
- ~partitionsCount,
1451
- ),
1452
- chainId: 0,
1453
- progress: 0.,
1440
+ itemsTarget,
1454
1441
  addressesByContractName,
1455
1442
  })
1443
+ cost := cost.contents +. itemsTarget
1456
1444
  }
1457
1445
  }
1458
1446
  }
1459
1447
  }
1448
+ cost.contents
1449
+ }
1450
+
1451
+ // Per-partition mutable state threaded through the water-fill rounds below.
1452
+ type waterFillState = {
1453
+ partitionId: string,
1454
+ 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,
1460
+ queryEndBlock: option<int>,
1461
+ 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>,
1460
1466
  }
1461
1467
 
1462
- // Candidate queries are sized against the natural ceiling (head/endBlock/mergeBlock),
1463
- // not a share of the shared bufferCrossChainState.checkAndFetch's admission loop
1464
- // is what actually bounds how much gets fetched per tick, by estResponseSize (which
1465
- // the HyperSync/SVM sources also enforce server-side via a maxNumLogs-style cap, so
1466
- // a wrong estimate truncates the response instead of overshooting the buffer).
1468
+ // Candidate queries are sized against chainTargetBlock, the soft querying
1469
+ // horizon the owning chain wants to reach this tick derived by ChainState
1470
+ // from its share of the indexer-wide buffer budget and its chain-level event
1471
+ // density. chainTargetBlock is never used as a hard query end, only to (a)
1472
+ // select which partitions are "in range" this tick and (b) size an open-ended
1473
+ // query with no other ceiling: the true hard bounds stay
1474
+ // endBlock/mergeBlock/the lagged head.
1475
+ //
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.
1482
+ //
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.
1467
1491
  let getNextQuery = (
1468
1492
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1493
+ ~chainTargetBlock: int,
1494
+ ~chainTargetItems: float,
1469
1495
  ) => {
1470
1496
  let headBlockNumber = knownHeight - blockLag
1471
1497
  if headBlockNumber <= 0 {
@@ -1480,20 +1506,17 @@ let getNextQuery = (
1480
1506
  !isOnBlockBehindTheHead,
1481
1507
  )
1482
1508
 
1483
- let queries = []
1484
-
1485
1509
  let partitionsCount = optimizedPartitions.idsInAscOrder->Array.length
1486
- let idxRef = ref(0)
1487
- while idxRef.contents < partitionsCount {
1488
- let idx = idxRef.contents
1510
+
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.
1514
+ for idx in 0 to partitionsCount - 1 {
1489
1515
  let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1490
1516
  let p = optimizedPartitions.entities->Dict.getUnsafe(partitionId)
1491
-
1492
- let pendingCount = p.mutPendingQueries->Array.length
1493
- let isBehindTheHead = p.latestFetchedBlock.blockNumber < headBlockNumber
1494
- let hasPendingQueries = pendingCount > 0
1495
-
1496
- if hasPendingQueries || isBehindTheHead {
1517
+ if (
1518
+ p.mutPendingQueries->Array.length > 0 || p.latestFetchedBlock.blockNumber < headBlockNumber
1519
+ ) {
1497
1520
  // Even if there are some partitions waiting for the new block
1498
1521
  // We still want to wait for all partitions reaching the head
1499
1522
  // because they might update knownHeight in their response
@@ -1501,46 +1524,84 @@ let getNextQuery = (
1501
1524
  // and we don't want to poll the head for a few small partitions
1502
1525
  shouldWaitForNewBlock := false
1503
1526
  }
1527
+ }
1504
1528
 
1505
- let partitionQueriesStart = queries->Array.length
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
1531
+ // the end (see below) reproduces idsInAscOrder without a sort.
1532
+ let queriesByPartitionIndex: array<
1533
+ array<query>,
1534
+ > = Array.fromInitializer(~length=partitionsCount, _ => [])
1506
1535
 
1507
- // Compute queryEndBlock for this partition
1536
+ // Compute queryEndBlock for this partition
1537
+ let computeQueryEndBlock = (p: partition) => {
1508
1538
  let queryEndBlock = Utils.Math.minOptInt(endBlock, p.mergeBlock)
1509
- let queryEndBlock = switch blockLag {
1539
+ switch blockLag {
1510
1540
  | 0 => queryEndBlock
1511
1541
  | _ =>
1512
1542
  // Force head block as an endBlock when blockLag is set
1513
1543
  // because otherwise HyperSync might return bigger range
1514
1544
  Utils.Math.minOptInt(Some(headBlockNumber), queryEndBlock)
1515
1545
  }
1546
+ }
1547
+
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
+ }
1516
1565
 
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
+ let fillStates = []
1572
+ let gapFillCost = ref(0.)
1573
+ for idx in 0 to partitionsCount - 1 {
1574
+ let partitionId = optimizedPartitions.idsInAscOrder->Array.getUnsafe(idx)
1575
+ 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)
1517
1579
  let maybeChunkRange = getMinHistoryRange(p)
1518
1580
 
1519
- // Walk pending queries to find open ranges and create queries for each
1520
1581
  let cursor = ref(p.latestFetchedBlock.blockNumber + 1)
1521
1582
  let canContinue = ref(true)
1583
+ let chunksUsedThisCall = ref(0)
1522
1584
  let pqIdx = ref(0)
1523
- while pqIdx.contents < p.mutPendingQueries->Array.length && canContinue.contents {
1585
+ while pqIdx.contents < pendingCount && canContinue.contents {
1524
1586
  let pq = p.mutPendingQueries->Array.getUnsafe(pqIdx.contents)
1525
1587
 
1526
- // Gap before this pending query → create queries for the gap range
1527
1588
  if pq.fromBlock > cursor.contents {
1528
- pushQueriesForRange(
1529
- queries,
1589
+ let beforeLen = bucket->Array.length
1590
+ let cost = pushGapFillQueries(
1591
+ bucket,
1530
1592
  ~partitionId,
1531
1593
  ~rangeFromBlock=cursor.contents,
1532
1594
  ~rangeEndBlock=Utils.Math.minOptInt(Some(pq.fromBlock - 1), queryEndBlock),
1533
1595
  ~knownHeight,
1596
+ ~chainTargetBlock,
1534
1597
  ~maybeChunkRange,
1535
- ~maxChunks=maxPendingChunksPerPartition -
1536
- pendingCount -
1537
- (queries->Array.length -
1538
- partitionQueriesStart),
1598
+ ~maxChunks=maxPendingChunksPerPartition - pendingCount - chunksUsedThisCall.contents,
1539
1599
  ~partition=p,
1540
1600
  ~selection=p.selection,
1541
1601
  ~addressesByContractName=p.addressesByContractName,
1542
- ~partitionsCount,
1543
1602
  )
1603
+ chunksUsedThisCall := chunksUsedThisCall.contents + (bucket->Array.length - beforeLen)
1604
+ gapFillCost := gapFillCost.contents +. cost
1544
1605
  }
1545
1606
  switch pq {
1546
1607
  | {isChunk: true, toBlock: Some(toBlock), fetchedBlock: Some({blockNumber})}
@@ -1552,29 +1613,180 @@ let getNextQuery = (
1552
1613
  pqIdx := pqIdx.contents + 1
1553
1614
  }
1554
1615
 
1555
- // Tail range after all pending queries
1556
1616
  if canContinue.contents {
1557
- pushQueriesForRange(
1558
- queries,
1559
- ~partitionId,
1560
- ~rangeFromBlock=cursor.contents,
1561
- ~rangeEndBlock=queryEndBlock,
1562
- ~knownHeight,
1563
- ~maybeChunkRange,
1564
- ~maxChunks=maxPendingChunksPerPartition -
1565
- pendingCount -
1566
- (queries->Array.length -
1567
- partitionQueriesStart),
1568
- ~partition=p,
1569
- ~selection=p.selection,
1570
- ~addressesByContractName=p.addressesByContractName,
1571
- ~partitionsCount,
1572
- )
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
1573
1629
  }
1630
+ }
1574
1631
 
1575
- idxRef := idxRef.contents + 1
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
+ }
1729
+
1730
+ let isInRange = (fs: waterFillState) =>
1731
+ fs.cursor <= chainTargetBlock &&
1732
+ switch fs.queryEndBlock {
1733
+ | Some(eb) => fs.cursor <= eb
1734
+ | None => true
1735
+ } &&
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
1576
1782
  }
1577
1783
 
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.
1788
+ let queries = queriesByPartitionIndex->Array.flat
1789
+
1578
1790
  if queries->Utils.Array.isEmpty {
1579
1791
  if shouldWaitForNewBlock.contents {
1580
1792
  WaitingForNewBlock
@@ -1623,7 +1835,7 @@ Instantiates a fetch state with partitions for initial addresses
1623
1835
  let make = (
1624
1836
  ~startBlock,
1625
1837
  ~endBlock,
1626
- ~eventConfigs: array<Internal.eventConfig>,
1838
+ ~onEventRegistrations: array<Internal.onEventRegistration>,
1627
1839
  ~contractConfigs: dict<IndexingAddresses.contractConfig>,
1628
1840
  ~addresses: array<Internal.indexingAddress>,
1629
1841
  ~maxAddrInPartition,
@@ -1631,7 +1843,7 @@ let make = (
1631
1843
  ~maxOnBlockBufferSize,
1632
1844
  ~knownHeight,
1633
1845
  ~progressBlockNumber=startBlock - 1,
1634
- ~onBlockConfigs=[],
1846
+ ~onBlockRegistrations=[],
1635
1847
  ~blockLag=0,
1636
1848
  ~firstEventBlock=None,
1637
1849
  ): t => {
@@ -1641,15 +1853,15 @@ let make = (
1641
1853
  }
1642
1854
 
1643
1855
  let notDependingOnAddresses = []
1644
- let normalEventConfigs = []
1856
+ let normalRegistrations = []
1645
1857
  let contractNamesWithNormalEvents = Utils.Set.make()
1646
1858
 
1647
- eventConfigs->Array.forEach(ec => {
1648
- if ec.dependsOnAddresses {
1649
- normalEventConfigs->Array.push(ec)
1650
- contractNamesWithNormalEvents->Utils.Set.add(ec.contractName)->ignore
1859
+ onEventRegistrations->Array.forEach(reg => {
1860
+ if reg.dependsOnAddresses {
1861
+ normalRegistrations->Array.push(reg)
1862
+ contractNamesWithNormalEvents->Utils.Set.add(reg.eventConfig.contractName)->ignore
1651
1863
  } else {
1652
- notDependingOnAddresses->Array.push(ec)
1864
+ notDependingOnAddresses->Array.push(reg)
1653
1865
  }
1654
1866
  })
1655
1867
 
@@ -1661,7 +1873,7 @@ let make = (
1661
1873
  latestFetchedBlock,
1662
1874
  selection: {
1663
1875
  dependsOnAddresses: false,
1664
- eventConfigs: notDependingOnAddresses,
1876
+ onEventRegistrations: notDependingOnAddresses,
1665
1877
  },
1666
1878
  addressesByContractName: Dict.make(),
1667
1879
  mergeBlock: None,
@@ -1676,7 +1888,7 @@ let make = (
1676
1888
 
1677
1889
  let normalSelection = {
1678
1890
  dependsOnAddresses: true,
1679
- eventConfigs: normalEventConfigs,
1891
+ onEventRegistrations: normalRegistrations,
1680
1892
  }
1681
1893
 
1682
1894
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
@@ -1712,12 +1924,15 @@ let make = (
1712
1924
  ~progressBlockNumber,
1713
1925
  )
1714
1926
 
1715
- if optimizedPartitions->OptimizedPartitions.count === 0 && onBlockConfigs->Utils.Array.isEmpty {
1927
+ if (
1928
+ optimizedPartitions->OptimizedPartitions.count === 0 &&
1929
+ onBlockRegistrations->Utils.Array.isEmpty
1930
+ ) {
1716
1931
  JsError.throwWithMessage(
1717
1932
  `Invalid configuration: Nothing to fetch on chain ${chainId->Int.toString}. ` ++
1718
1933
  `addresses=${addresses->Array.length->Int.toString}, ` ++
1719
- `eventConfigs=${eventConfigs->Array.length->Int.toString}, ` ++
1720
- `normalEventConfigs=${normalEventConfigs
1934
+ `onEventRegistrations=${onEventRegistrations->Array.length->Int.toString}, ` ++
1935
+ `normalRegistrations=${normalRegistrations
1721
1936
  ->Array.length
1722
1937
  ->Int.toString}. ` ++ `Make sure that you provided at least one contract address to index, or have events with Wildcard mode enabled, or have onBlock handlers.`,
1723
1938
  )
@@ -1728,14 +1943,14 @@ let make = (
1728
1943
  // fetching, so without seeding the buffer here getNextQuery would return
1729
1944
  // NothingToQuery and the indexer would get stuck.
1730
1945
  let buffer = []
1731
- let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockConfigs->Utils.Array.notEmpty {
1946
+ let latestOnBlockBlockNumber = if knownHeight > 0 && onBlockRegistrations->Utils.Array.notEmpty {
1732
1947
  let maxBlockNumber = switch optimizedPartitions->OptimizedPartitions.getLatestFullyFetchedBlock {
1733
1948
  | None => knownHeight
1734
1949
  | Some(latestFullyFetchedBlock) => latestFullyFetchedBlock.blockNumber
1735
1950
  }
1736
1951
  appendOnBlockItems(
1737
1952
  ~mutItems=buffer,
1738
- ~onBlockConfigs,
1953
+ ~onBlockRegistrations,
1739
1954
  ~indexerStartBlock=startBlock,
1740
1955
  ~fromBlock=progressBlockNumber,
1741
1956
  ~maxBlockNumber,
@@ -1754,7 +1969,7 @@ let make = (
1754
1969
  latestOnBlockBlockNumber,
1755
1970
  normalSelection,
1756
1971
  blockLag,
1757
- onBlockConfigs,
1972
+ onBlockRegistrations,
1758
1973
  maxOnBlockBufferSize,
1759
1974
  knownHeight,
1760
1975
  buffer,
@@ -2004,17 +2219,6 @@ let getProgressPercentage = (fetchState: t) => {
2004
2219
  }
2005
2220
  }
2006
2221
 
2007
- // Progress a specific block sits at along the chain, used to order queries from
2008
- // different chains: a query starting further back (lower %) is fetched first.
2009
- let getProgressPercentageAt = (fetchState: t, ~blockNumber) => {
2010
- switch fetchState.firstEventBlock {
2011
- | None => 0.
2012
- | Some(firstEventBlock) =>
2013
- let totalRange = fetchState.knownHeight - firstEventBlock
2014
- totalRange <= 0 ? 0. : (blockNumber - firstEventBlock)->Int.toFloat /. totalRange->Int.toFloat
2015
- }
2016
- }
2017
-
2018
2222
  let sortForBatch = {
2019
2223
  let hasFullBatch = ({buffer} as fetchState: t, ~batchSizeTarget) => {
2020
2224
  switch buffer->Array.get(batchSizeTarget - 1) {