envio 3.4.0 → 3.5.0-alpha.1

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.
@@ -9,18 +9,27 @@ type blockNumberAndLogIndex = {blockNumber: int, logIndex: int}
9
9
 
10
10
  type selection = {
11
11
  onEventRegistrations: array<Internal.onEventRegistration>,
12
+ // Whether the partition's queries are built from its own address list
13
+ // (server-side address filtering). This is the *partition* property; not to
14
+ // be confused with the per-registration `Internal.onEventRegistration.dependsOnAddresses`.
12
15
  dependsOnAddresses: bool,
16
+ // Contract names this query fetches address-free even though their
17
+ // registrations depend on addresses. The source passes these to the client so
18
+ // their log selections carry no server-side address filter and routing accepts
19
+ // any emitter; the JS `clientAddressFilter` still gates each item by registered
20
+ // address + effectiveStartBlock. Absent for normal partitions.
21
+ clientFilteredContracts?: array<string>,
13
22
  }
14
23
 
15
24
  type pendingQuery = {
16
25
  fromBlock: int,
17
26
  toBlock: option<int>,
18
27
  isChunk: bool,
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.
28
+ // Server maxNumLogs-style cap for this in-flight query. None for bounded
29
+ // chunk/gap-fill queries, which send no cap (their toBlock is the bound).
30
+ itemsTarget: option<int>,
31
+ // Estimated items this in-flight query will return, carried from the query so
32
+ // the shared buffer budget can account for what's already being fetched.
24
33
  itemsEst: int,
25
34
  // Stores latestFetchedBlock when query completes. Only needed to persist
26
35
  // timestamp while earlier queries are still pending before updating
@@ -72,14 +81,15 @@ type query = {
72
81
  fromBlock: int,
73
82
  toBlock: option<int>,
74
83
  isChunk: bool,
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.
84
+ // Server-side maxNumLogs-style cap. Some only for open-ended probes, whose
85
+ // range isn't otherwise bounded; None for bounded chunk/gap-fill queries,
86
+ // whose toBlock already bounds the response so a cap would only self-truncate
87
+ // (worse under client-side address filtering, which counts filtered-out items
88
+ // toward the cap).
89
+ itemsTarget: option<int>,
90
+ // Density × the query's block range for a known-density partition, the
91
+ // query's budget share otherwise. This is the unit the chain's per-tick
92
+ // budget is reserved/consumed in and that sizes every query.
83
93
  itemsEst: int,
84
94
  selection: selection,
85
95
  addressesByContractName: dict<array<Address.t>>,
@@ -103,13 +113,13 @@ let deriveContractNameByAddress: dict<array<Address.t>> => dict<
103
113
  result
104
114
  })
105
115
 
106
- // itemsTarget for a query over [fromBlock, toBlock] at the given event density
116
+ // itemsEst for a query over [fromBlock, toBlock] at the given event density
107
117
  // (items/block). toBlock None is the open-ended tail, capped at
108
118
  // chainTargetBlock — the soft per-tick horizon the owning chain wants to reach
109
119
  // (see getNextQuery).
110
120
  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.
121
+ // Floor at 1: this is the budget reservation, and for a probe also its server
122
+ // cap a 0 cap would ask the backend for nothing.
113
123
  Pervasives.max(
114
124
  1,
115
125
  ((toBlock->Option.getOr(chainTargetBlock) - fromBlock + 1)->Int.toFloat *. density)
@@ -159,6 +169,12 @@ module OptimizedPartitions = {
159
169
  // Tracks all contract names that have been dynamically added.
160
170
  // Never reset - used to determine when to split existing partitions.
161
171
  dynamicContracts: Utils.Set.t<string>,
172
+ // Contract names switched to client-side address filtering once their
173
+ // registered address count crossed the server-side threshold. Sticky for
174
+ // the run: their addresses live only in the index, their events are fetched
175
+ // by the single address-free partition, and new dynamic addresses get a
176
+ // bounded backfill up to its frontier instead of a standing partition.
177
+ clientFilteredContracts: Utils.Set.t<string>,
162
178
  }
163
179
 
164
180
  @inline
@@ -283,6 +299,7 @@ module OptimizedPartitions = {
283
299
  ~maxAddrInPartition,
284
300
  ~nextPartitionIndex: int,
285
301
  ~dynamicContracts: Utils.Set.t<string>,
302
+ ~clientFilteredContracts: Utils.Set.t<string>,
286
303
  ) => {
287
304
  let newPartitions = []
288
305
  let mergingPartitions = Dict.make()
@@ -416,6 +433,7 @@ module OptimizedPartitions = {
416
433
  maxAddrInPartition,
417
434
  nextPartitionIndex: nextPartitionIndexRef.contents,
418
435
  dynamicContracts,
436
+ clientFilteredContracts,
419
437
  }
420
438
  }
421
439
 
@@ -467,14 +485,37 @@ module OptimizedPartitions = {
467
485
  }
468
486
  }
469
487
 
470
- let handleQueryResponse = (
488
+ let rec handleQueryResponse = (
471
489
  optimizedPartitions: t,
472
490
  ~query,
473
491
  ~knownHeight,
474
492
  ~itemsCount,
475
493
  ~latestFetchedBlock: blockNumberAndTimestamp,
494
+ ) =>
495
+ switch optimizedPartitions.entities->Utils.Dict.dangerouslyGetNonOption(query.partitionId) {
496
+ // The partition was absorbed into the address-free client-filtered partition
497
+ // while this query was in flight. Its items are still merged into the buffer
498
+ // by the caller (and deduped); there's no partition bookkeeping left to do,
499
+ // and its reservation is released by ChainState regardless.
500
+ | None => optimizedPartitions
501
+ | Some(p) =>
502
+ optimizedPartitions->handleQueryResponseForPartition(
503
+ ~p,
504
+ ~query,
505
+ ~knownHeight,
506
+ ~itemsCount,
507
+ ~latestFetchedBlock,
508
+ )
509
+ }
510
+
511
+ and handleQueryResponseForPartition = (
512
+ optimizedPartitions: t,
513
+ ~p: partition,
514
+ ~query,
515
+ ~knownHeight,
516
+ ~itemsCount,
517
+ ~latestFetchedBlock: blockNumberAndTimestamp,
476
518
  ) => {
477
- let p = optimizedPartitions->getOrThrow(~partitionId=query.partitionId)
478
519
  let mutEntities = optimizedPartitions.entities->Utils.Dict.shallowCopy
479
520
 
480
521
  // Mark query as fetched
@@ -508,8 +549,12 @@ module OptimizedPartitions = {
508
549
  if latestFetchedBlock.blockNumber < queryToBlock {
509
550
  // Partial response is direct capacity evidence — unless it was
510
551
  // truncated by our own itemsTarget cap: that reflects the
511
- // reservation we asked for, not what the server could return.
512
- itemsCount < query.itemsTarget
552
+ // reservation we asked for, not what the server could return. A
553
+ // capless bounded query can only have been truncated by the source.
554
+ switch query.itemsTarget {
555
+ | None => true
556
+ | Some(itemsTarget) => itemsCount < itemsTarget
557
+ }
513
558
  } else {
514
559
  // A full response updates only when the query's intended range
515
560
  // covers at least the partition's current chunk range — meaning it
@@ -549,6 +594,7 @@ module OptimizedPartitions = {
549
594
  ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
550
595
  ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
551
596
  ~dynamicContracts=optimizedPartitions.dynamicContracts,
597
+ ~clientFilteredContracts=optimizedPartitions.clientFilteredContracts,
552
598
  )
553
599
  } else {
554
600
  let updatedMainPartition = {
@@ -598,6 +644,7 @@ module OptimizedPartitions = {
598
644
  ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
599
645
  ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
600
646
  ~dynamicContracts=optimizedPartitions.dynamicContracts,
647
+ ~clientFilteredContracts=optimizedPartitions.clientFilteredContracts,
601
648
  )
602
649
  }
603
650
  }
@@ -638,6 +685,11 @@ type t = {
638
685
  onBlockRegistrations: array<Internal.onBlockRegistration>,
639
686
  knownHeight: int,
640
687
  firstEventBlock: option<int>,
688
+ // Per-contract registered-address count past which a dynamic contract is
689
+ // switched to client-side filtering. None disables the switch
690
+ // (sources that can't filter client-side, e.g. SVM/Fuel), leaving every
691
+ // contract filtered server-side.
692
+ clientFilterAddressThreshold: option<int>,
641
693
  }
642
694
 
643
695
  @inline
@@ -907,6 +959,7 @@ let updateInternal = (
907
959
  | blockItems => base->mergeIntoBuffer(blockItems)
908
960
  },
909
961
  firstEventBlock: fetchState.firstEventBlock,
962
+ clientFilterAddressThreshold: fetchState.clientFilterAddressThreshold,
910
963
  }
911
964
 
912
965
  updatedFetchState
@@ -946,6 +999,209 @@ let addressesByContractNameGetAll = (addressesByContractName: dict<array<Address
946
999
  all
947
1000
  }
948
1001
 
1002
+ // Move a contract to client-side address filtering, recording why.
1003
+ let addClientFilteredContract = (
1004
+ clientFilteredContracts: Utils.Set.t<string>,
1005
+ ~contractName,
1006
+ ~chainId,
1007
+ ~addressCount,
1008
+ ~threshold,
1009
+ ) => {
1010
+ clientFilteredContracts->Utils.Set.add(contractName)->ignore
1011
+ Logging.createChild(
1012
+ ~params={
1013
+ "chainId": chainId,
1014
+ "contractName": contractName,
1015
+ "addressCount": addressCount,
1016
+ "threshold": threshold,
1017
+ },
1018
+ )->Logging.childTrace(
1019
+ "Switching contract to client-side address filtering: registered address count crossed the server-side threshold.",
1020
+ )
1021
+ }
1022
+
1023
+ // Fold every client-filtered contract's server-side partitions into client-side
1024
+ // fetching without tearing down established state:
1025
+ // - The standing address-free partition (no mergeBlock) keeps its id, frontier,
1026
+ // in-flight queries and learned density. Only when a newly-switched contract
1027
+ // must be added does its selection change — under a fresh id, so in-flight
1028
+ // responses built from the old selection are orphaned instead of advancing
1029
+ // the frontier past ranges the new contract wasn't fetched for.
1030
+ // - Partitions absorbed below the standing frontier (single-contract dynamic
1031
+ // partitions and config partitions all of whose contracts are
1032
+ // client-filtered, plus any prior backfill) become one bounded backfill
1033
+ // partition covering [their min frontier, standing frontier]: getNextQuery
1034
+ // caps its queries at mergeBlock and handleQueryResponse deletes it on
1035
+ // arrival. The overlap it re-delivers is deduped by mergeIntoBuffer, and the
1036
+ // re-fetch doubles as history for freshly registered addresses: events
1037
+ // dropped before the address was registered now pass the clientAddressFilter.
1038
+ // - A partition mixing client-filtered and server-side contracts stays,
1039
+ // stripped of the client-filtered contracts' addresses — the address-free
1040
+ // partition covers those logs, so fetching them server-side too would only
1041
+ // produce duplicates. Its frontier bounds the backfill from below so the
1042
+ // stripped contracts lose no range.
1043
+ //
1044
+ // Registrations come from the address-free partitions plus `normalSelection`
1045
+ // filtered to client-filtered contracts, so coverage never depends on which
1046
+ // partitions happened to be absorbed (a stripped contract may have no
1047
+ // absorbable partition at all).
1048
+ let collapseClientFilteredContracts = (
1049
+ partitions: array<partition>,
1050
+ ~clientFilteredContracts: Utils.Set.t<string>,
1051
+ ~normalSelection: selection,
1052
+ ~nextPartitionIndexRef: ref<int>,
1053
+ ) => {
1054
+ if clientFilteredContracts->Utils.Set.size === 0 {
1055
+ partitions
1056
+ } else {
1057
+ let kept = []
1058
+ let standingRef = ref(None)
1059
+ let backfills = []
1060
+ let absorbedPartitions = []
1061
+ let strippedFrontiers = []
1062
+
1063
+ partitions->Array.forEach(p =>
1064
+ switch p {
1065
+ | {selection: {dependsOnAddresses: false}, mergeBlock: None}
1066
+ if standingRef.contents->Option.isNone =>
1067
+ standingRef := Some(p)
1068
+ | {selection: {dependsOnAddresses: false}, mergeBlock: Some(_)} =>
1069
+ backfills->Array.push(p)->ignore
1070
+ | {selection: {dependsOnAddresses: false}} => absorbedPartitions->Array.push(p)->ignore
1071
+ | _ =>
1072
+ let contractNames = p.addressesByContractName->Dict.keysToArray
1073
+ let clientFilteredNames =
1074
+ contractNames->Array.filter(c => clientFilteredContracts->Utils.Set.has(c))
1075
+ if clientFilteredNames->Utils.Array.isEmpty {
1076
+ kept->Array.push(p)->ignore
1077
+ } else if clientFilteredNames->Array.length === contractNames->Array.length {
1078
+ absorbedPartitions->Array.push(p)->ignore
1079
+ } else {
1080
+ let stripped = p.addressesByContractName->Utils.Dict.shallowCopy
1081
+ clientFilteredNames->Array.forEach(c => stripped->Utils.Dict.deleteInPlace(c))
1082
+ strippedFrontiers->Array.push(p.latestFetchedBlock)->ignore
1083
+ kept->Array.push({...p, addressesByContractName: stripped})->ignore
1084
+ }
1085
+ }
1086
+ )
1087
+
1088
+ let selectionChanged = switch standingRef.contents {
1089
+ | Some(standing) =>
1090
+ standing.selection.clientFilteredContracts->Option.mapOr(0, Array.length) !==
1091
+ clientFilteredContracts->Utils.Set.size
1092
+ | None => true
1093
+ }
1094
+
1095
+ if (
1096
+ absorbedPartitions->Utils.Array.isEmpty &&
1097
+ strippedFrontiers->Utils.Array.isEmpty &&
1098
+ !selectionChanged
1099
+ ) {
1100
+ // Nothing to fold in and no newly-switched contract: leave the standing
1101
+ // partition (and any in-progress backfill) untouched.
1102
+ partitions
1103
+ } else {
1104
+ let minFrontierRef: ref<option<blockNumberAndTimestamp>> = ref(None)
1105
+ let considerFrontier = (b: blockNumberAndTimestamp) =>
1106
+ switch minFrontierRef.contents {
1107
+ | Some(m) if m.blockNumber <= b.blockNumber => ()
1108
+ | _ => minFrontierRef := Some(b)
1109
+ }
1110
+ absorbedPartitions->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1111
+ backfills->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1112
+ strippedFrontiers->Array.forEach(considerFrontier)
1113
+
1114
+ let regByIndex = Dict.make()
1115
+ let addRegs = (regs: array<Internal.onEventRegistration>) =>
1116
+ regs->Array.forEach(reg => regByIndex->Dict.set(reg.index->Int.toString, reg))
1117
+ switch standingRef.contents {
1118
+ | Some(standing) => addRegs(standing.selection.onEventRegistrations)
1119
+ | None => ()
1120
+ }
1121
+ absorbedPartitions->Array.forEach(p =>
1122
+ if !p.selection.dependsOnAddresses {
1123
+ addRegs(p.selection.onEventRegistrations)
1124
+ }
1125
+ )
1126
+ addRegs(
1127
+ normalSelection.onEventRegistrations->Array.filter(reg =>
1128
+ clientFilteredContracts->Utils.Set.has(reg.eventConfig.contractName)
1129
+ ),
1130
+ )
1131
+ let newSelection = {
1132
+ dependsOnAddresses: false,
1133
+ onEventRegistrations: regByIndex->Dict.valuesToArray,
1134
+ clientFilteredContracts: clientFilteredContracts->Utils.Set.toArray,
1135
+ }
1136
+
1137
+ switch standingRef.contents {
1138
+ | None =>
1139
+ // First switch: no standing partition to preserve, so create it at the
1140
+ // min frontier directly — the whole range above is unfetched for the
1141
+ // client-filtered side anyway.
1142
+ switch minFrontierRef.contents {
1143
+ | None => kept
1144
+ | Some(minFrontier) =>
1145
+ let minRange = getMinQueryRange(absorbedPartitions)
1146
+ let id = nextPartitionIndexRef.contents->Int.toString
1147
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1148
+ kept
1149
+ ->Array.push({
1150
+ id,
1151
+ latestFetchedBlock: minFrontier,
1152
+ selection: newSelection,
1153
+ addressesByContractName: Dict.make(),
1154
+ mergeBlock: None,
1155
+ dynamicContract: None,
1156
+ mutPendingQueries: [],
1157
+ sourceRangeCapacity: minRange,
1158
+ prevSourceRangeCapacity: minRange,
1159
+ eventDensity: None,
1160
+ latestSourceRangeCapacityUpdateBlock: 0,
1161
+ })
1162
+ ->ignore
1163
+ kept
1164
+ }
1165
+ | Some(standing) =>
1166
+ let standingOut = if selectionChanged {
1167
+ let id = nextPartitionIndexRef.contents->Int.toString
1168
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1169
+ {...standing, id, selection: newSelection, mutPendingQueries: []}
1170
+ } else {
1171
+ standing
1172
+ }
1173
+ kept->Array.push(standingOut)->ignore
1174
+ switch minFrontierRef.contents {
1175
+ | Some(minFrontier) if minFrontier.blockNumber < standing.latestFetchedBlock.blockNumber =>
1176
+ let id = nextPartitionIndexRef.contents->Int.toString
1177
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1178
+ kept
1179
+ ->Array.push({
1180
+ id,
1181
+ latestFetchedBlock: minFrontier,
1182
+ selection: newSelection,
1183
+ addressesByContractName: Dict.make(),
1184
+ mergeBlock: Some(standing.latestFetchedBlock.blockNumber),
1185
+ dynamicContract: None,
1186
+ mutPendingQueries: [],
1187
+ // Same query shape as the standing partition, so inherit its
1188
+ // learned range and density instead of probing from scratch.
1189
+ sourceRangeCapacity: standing.sourceRangeCapacity,
1190
+ prevSourceRangeCapacity: standing.prevSourceRangeCapacity,
1191
+ eventDensity: standing.eventDensity,
1192
+ latestSourceRangeCapacityUpdateBlock: 0,
1193
+ })
1194
+ ->ignore
1195
+ // An absorbed frontier at/above the standing frontier needs no
1196
+ // backfill: the standing partition hasn't fetched past there yet.
1197
+ | _ => ()
1198
+ }
1199
+ kept
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+
949
1205
  /**
950
1206
  Creates partitions from indexing addresses with two phases:
951
1207
  Phase 1: Create per-contract-name partitions (smart grouping by startBlock)
@@ -956,6 +1212,7 @@ Returns OptimizedPartitions.t directly.
956
1212
  let createPartitionsFromIndexingAddresses = (
957
1213
  ~registeringContractsByContract: dict<dict<indexingAddress>>,
958
1214
  ~dynamicContracts: Utils.Set.t<string>,
1215
+ ~clientFilteredContracts: Utils.Set.t<string>,
959
1216
  ~normalSelection: selection,
960
1217
  ~maxAddrInPartition: int,
961
1218
  ~nextPartitionIndex: int,
@@ -1127,12 +1384,22 @@ OptimizedPartitions.t => {
1127
1384
 
1128
1385
  let mergedPartitions = mergedNonDynamic->Array.concat(dynamicPartitions)
1129
1386
 
1130
- // Final step: concat existing partitions with phase 1+2 result and call OptimizedPartitions.make
1387
+ // Final step: concat existing partitions with phase 1+2 result, collapse any
1388
+ // client-filtered contracts into the single address-free partition, and optimize.
1389
+ let allPartitions =
1390
+ existingPartitions
1391
+ ->Array.concat(mergedPartitions)
1392
+ ->collapseClientFilteredContracts(
1393
+ ~clientFilteredContracts,
1394
+ ~normalSelection,
1395
+ ~nextPartitionIndexRef,
1396
+ )
1131
1397
  OptimizedPartitions.make(
1132
- ~partitions=existingPartitions->Array.concat(mergedPartitions),
1398
+ ~partitions=allPartitions,
1133
1399
  ~maxAddrInPartition,
1134
1400
  ~nextPartitionIndex=nextPartitionIndexRef.contents,
1135
1401
  ~dynamicContracts,
1402
+ ~clientFilteredContracts,
1136
1403
  )
1137
1404
  }
1138
1405
 
@@ -1392,9 +1659,39 @@ let registerDynamicContracts = (
1392
1659
  // Include no-events dcs so later batches detect conflicts against them.
1393
1660
  indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1394
1661
 
1662
+ // Switch any dynamic contract that has just crossed the server-side
1663
+ // address threshold to client-side filtering. Sticky: the set only grows, and
1664
+ // collapse in createPartitionsFromIndexingAddresses folds the contract's
1665
+ // partitions into the single address-free partition.
1666
+ // Clone the sticky set before mutating so this update owns its copy and
1667
+ // older fetchState snapshots keep theirs (Utils.Set.add mutates in place).
1668
+ let clientFilteredContracts = switch fetchState.clientFilterAddressThreshold {
1669
+ | Some(threshold) =>
1670
+ let clientFilteredContracts =
1671
+ fetchState.optimizedPartitions.clientFilteredContracts
1672
+ ->Utils.Set.toArray
1673
+ ->Utils.Set.fromArray
1674
+ dynamicContractsRef.contents
1675
+ ->Utils.Set.toArray
1676
+ ->Array.forEach(contractName => {
1677
+ let addressCount = indexingAddresses->IndexingAddresses.contractCount(~contractName)
1678
+ if !(clientFilteredContracts->Utils.Set.has(contractName)) && addressCount > threshold {
1679
+ clientFilteredContracts->addClientFilteredContract(
1680
+ ~contractName,
1681
+ ~chainId=fetchState.chainId,
1682
+ ~addressCount,
1683
+ ~threshold,
1684
+ )
1685
+ }
1686
+ })
1687
+ clientFilteredContracts
1688
+ | None => fetchState.optimizedPartitions.clientFilteredContracts
1689
+ }
1690
+
1395
1691
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
1396
1692
  ~registeringContractsByContract,
1397
1693
  ~dynamicContracts=dynamicContractsRef.contents,
1694
+ ~clientFilteredContracts,
1398
1695
  ~normalSelection=fetchState.normalSelection,
1399
1696
  ~maxAddrInPartition=fetchState.optimizedPartitions.maxAddrInPartition,
1400
1697
  ~nextPartitionIndex=fetchState.optimizedPartitions.nextPartitionIndex +
@@ -1419,9 +1716,16 @@ let filterByClientAddress = (
1419
1716
  items->Array.filter(item =>
1420
1717
  switch item {
1421
1718
  | Internal.Event({payload, blockNumber}) as item =>
1422
- switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1719
+ let reg = (item->Internal.castUnsafeEventItem).onEventRegistration
1720
+ switch reg.clientAddressFilter {
1423
1721
  | Some(filter) =>
1424
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1722
+ filter(
1723
+ payload,
1724
+ blockNumber,
1725
+ indexingAddresses->IndexingAddresses.forContract(
1726
+ ~contractName=reg.eventConfig.contractName,
1727
+ ),
1728
+ )
1425
1729
  | None => true
1426
1730
  }
1427
1731
  | _ => true
@@ -1505,26 +1809,19 @@ let maxInFlightChunksPerPartition = 12
1505
1809
  // Most parallel in-flight queries a single chain may have at once, across all
1506
1810
  // its partitions. Bounds source load on chains with many partitions, where the
1507
1811
  // per-partition cap alone would admit thousands of concurrent queries.
1508
- let maxChainConcurrency = 100
1812
+ let maxChainConcurrency = Env.maxChainConcurrency
1509
1813
 
1510
1814
  // Chunk spans grow by this factor over the smallest recently observed source
1511
1815
  // range, so the pipeline keeps probing for more capacity instead of locking in
1512
1816
  // the first measurement.
1513
1817
  let chunkRangeGrowthFactor = 1.8
1514
1818
 
1515
- // Push one density-priced query and return its itemsEst. itemsEst is the honest
1516
- // density estimate the chain's budget is reserved in; itemsTarget is the
1517
- // server-side cap with chunkItemsMultiplier headroom so a denser-than-expected
1518
- // range doesn't truncate the response.
1519
- //
1520
- // A bounded query (toBlock set) additionally floors its cap at
1521
- // itemsTargetFloor: its range is already the hard bound on the response, so a
1522
- // low density estimate shrinking the cap only buys self-truncated responses —
1523
- // each one a wasted roundtrip that opens a gap and pollutes nothing but our
1524
- // own pipeline. The floor is the indexer target split across the chain's
1525
- // concurrency slots, so even every in-flight bounded query hitting its floored
1526
- // cap at once overshoots the pool by at most ~one buffer target. Open-ended
1527
- // queries keep the pure density cap — there it's the only bound at all.
1819
+ // Push one density-priced query and return its itemsEst, the density estimate
1820
+ // that sizes the query and the chain's budget reservation. These queries are
1821
+ // chunks and gap-fills: their toBlock is a tight bound sized to the source's
1822
+ // range capacity, so they send no server cap (itemsTarget None) — a cap would
1823
+ // only self-truncate the bounded range, worse under client-side address
1824
+ // filtering. A rare unbounded call caps at the estimate as its only bound.
1528
1825
  let pushDensityPricedQuery = (
1529
1826
  queries: array<query>,
1530
1827
  ~partitionId,
@@ -1532,19 +1829,11 @@ let pushDensityPricedQuery = (
1532
1829
  ~toBlock,
1533
1830
  ~isChunk,
1534
1831
  ~density,
1535
- ~chunkItemsMultiplier,
1536
1832
  ~chainTargetBlock,
1537
- ~itemsTargetFloor,
1538
1833
  ~selection,
1539
1834
  ~addressesByContractName,
1540
1835
  ) => {
1541
1836
  let itemsEst = densityItemsTarget(~density, ~fromBlock, ~toBlock, ~chainTargetBlock)
1542
- let itemsTarget = densityItemsTarget(
1543
- ~density=density *. chunkItemsMultiplier,
1544
- ~fromBlock,
1545
- ~toBlock,
1546
- ~chainTargetBlock,
1547
- )
1548
1837
  queries
1549
1838
  ->Array.push({
1550
1839
  partitionId,
@@ -1553,8 +1842,8 @@ let pushDensityPricedQuery = (
1553
1842
  selection,
1554
1843
  isChunk,
1555
1844
  itemsTarget: switch toBlock {
1556
- | Some(_) => Pervasives.max(itemsTarget, itemsTargetFloor)
1557
- | None => itemsTarget
1845
+ | Some(_) => None
1846
+ | None => Some(itemsEst)
1558
1847
  },
1559
1848
  itemsEst,
1560
1849
  addressesByContractName,
@@ -1585,8 +1874,6 @@ let pushGapFillQueries = (
1585
1874
  ~maxChunks: int,
1586
1875
  ~partition: partition,
1587
1876
  ~partitionBudget: float,
1588
- ~chunkItemsMultiplier: float,
1589
- ~itemsTargetFloor: int,
1590
1877
  ~selection: selection,
1591
1878
  ~addressesByContractName: dict<array<Address.t>>,
1592
1879
  ) => {
@@ -1610,9 +1897,7 @@ let pushGapFillQueries = (
1610
1897
  ~toBlock=rangeEndBlock,
1611
1898
  ~isChunk,
1612
1899
  ~density,
1613
- ~chunkItemsMultiplier,
1614
1900
  ~chainTargetBlock,
1615
- ~itemsTargetFloor,
1616
1901
  ~selection,
1617
1902
  ~addressesByContractName,
1618
1903
  )
@@ -1634,9 +1919,7 @@ let pushGapFillQueries = (
1634
1919
  ~toBlock=Some(chunkToBlock),
1635
1920
  ~isChunk=true,
1636
1921
  ~density,
1637
- ~chunkItemsMultiplier,
1638
1922
  ~chainTargetBlock,
1639
- ~itemsTargetFloor,
1640
1923
  ~selection,
1641
1924
  ~addressesByContractName,
1642
1925
  )
@@ -1684,8 +1967,6 @@ let walkPartitionPending = (
1684
1967
  ~candidates: array<query>,
1685
1968
  ~headBlockNumber: int,
1686
1969
  ~chainTargetBlock: int,
1687
- ~chunkItemsMultiplier: float,
1688
- ~itemsTargetFloor: int,
1689
1970
  ~partitionBudget: float,
1690
1971
  ~queryEndBlock: option<int>,
1691
1972
  ): option<partitionFillState> => {
@@ -1712,8 +1993,6 @@ let walkPartitionPending = (
1712
1993
  ~maxChunks=maxInFlightChunksPerPartition - inFlightCount - chunksUsedThisCall.contents,
1713
1994
  ~partition=p,
1714
1995
  ~partitionBudget,
1715
- ~chunkItemsMultiplier,
1716
- ~itemsTargetFloor,
1717
1996
  ~selection=p.selection,
1718
1997
  ~addressesByContractName=p.addressesByContractName,
1719
1998
  )
@@ -1757,14 +2036,12 @@ let pushForwardCandidates = (
1757
2036
  // bound, see getNextQuery.
1758
2037
  ~inRangeStates: array<partitionFillState>,
1759
2038
  // The full in-range partition count, pre-truncation. Probe sizing divides by
1760
- // this so each probe's itemsEst/itemsTarget stays the honest per-partition
1761
- // share for budget control — sizing by the (fewer) admittable queries would
1762
- // let every accepted probe over-fetch its share.
2039
+ // this so each probe's itemsEst stays the honest per-partition share for
2040
+ // budget control — sizing by the (fewer) admittable queries would let every
2041
+ // accepted probe over-fetch its share.
1763
2042
  ~inRangeCount: int,
1764
2043
  ~chainTargetBlock: int,
1765
2044
  ~freshBudget: float,
1766
- ~chunkItemsMultiplier: float,
1767
- ~itemsTargetFloor: int,
1768
2045
  ) => {
1769
2046
  // Even share of the fresh budget across the partitions actually fetching
1770
2047
  // this tick (not every partition — so budget isn't stranded on ones below
@@ -1818,9 +2095,7 @@ let pushForwardCandidates = (
1818
2095
  ~toBlock=Some(chunkToBlock),
1819
2096
  ~isChunk=true,
1820
2097
  ~density,
1821
- ~chunkItemsMultiplier,
1822
2098
  ~chainTargetBlock,
1823
- ~itemsTargetFloor,
1824
2099
  ~selection=p.selection,
1825
2100
  ~addressesByContractName=p.addressesByContractName,
1826
2101
  )
@@ -1834,7 +2109,7 @@ let pushForwardCandidates = (
1834
2109
  // across the partitions fetching this tick. With no range to the target
1835
2110
  // fall back to an even share of the fresh budget, so cold chains and
1836
2111
  // caught-up partitions still probe.
1837
- let itemsTarget = if rangeToTarget > 0 {
2112
+ let itemsEst = if rangeToTarget > 0 {
1838
2113
  Pervasives.max(
1839
2114
  1,
1840
2115
  Math.round(
@@ -1853,8 +2128,10 @@ let pushForwardCandidates = (
1853
2128
  toBlock: fs.queryEndBlock,
1854
2129
  isChunk: false,
1855
2130
  selection: p.selection,
1856
- itemsTarget,
1857
- itemsEst: itemsTarget,
2131
+ // An open-ended probe's range isn't bounded to source capacity, so it
2132
+ // keeps a server cap at its estimate to protect the shared buffer.
2133
+ itemsTarget: Some(itemsEst),
2134
+ itemsEst,
1858
2135
  addressesByContractName: p.addressesByContractName,
1859
2136
  })
1860
2137
  ->ignore
@@ -1958,10 +2235,6 @@ let getNextQuery = (
1958
2235
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1959
2236
  ~chainTargetBlock: int,
1960
2237
  ~chainTargetItems: float,
1961
- ~chunkItemsMultiplier: float=1.,
1962
- // Floor for bounded queries' server cap (see pushDensityPricedQuery) —
1963
- // targetBufferSize / maxChainConcurrency from the cross-chain scheduler.
1964
- ~itemsTargetFloor: int=0,
1965
2238
  ) => {
1966
2239
  let headBlockNumber = knownHeight - blockLag
1967
2240
  if headBlockNumber <= 0 {
@@ -2079,8 +2352,6 @@ let getNextQuery = (
2079
2352
  ~candidates,
2080
2353
  ~headBlockNumber,
2081
2354
  ~chainTargetBlock,
2082
- ~chunkItemsMultiplier,
2083
- ~itemsTargetFloor,
2084
2355
  ~partitionBudget,
2085
2356
  ~queryEndBlock=computeQueryEndBlock(p),
2086
2357
  ) {
@@ -2124,8 +2395,6 @@ let getNextQuery = (
2124
2395
  ~inRangeCount,
2125
2396
  ~chainTargetBlock,
2126
2397
  ~freshBudget,
2127
- ~chunkItemsMultiplier,
2128
- ~itemsTargetFloor,
2129
2398
  )
2130
2399
 
2131
2400
  acceptCandidates(
@@ -2197,6 +2466,7 @@ let make = (
2197
2466
  ~onBlockRegistrations=[],
2198
2467
  ~blockLag=0,
2199
2468
  ~firstEventBlock=None,
2469
+ ~clientFilterAddressThreshold=None,
2200
2470
  ): t => {
2201
2471
  let latestFetchedBlock = {
2202
2472
  blockTimestamp: 0,
@@ -2244,6 +2514,7 @@ let make = (
2244
2514
 
2245
2515
  let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
2246
2516
  let dynamicContracts = Utils.Set.make()
2517
+ let clientFilteredContracts = Utils.Set.make()
2247
2518
 
2248
2519
  addresses->Array.forEach(contract => {
2249
2520
  let contractName = contract.contractName
@@ -2265,9 +2536,29 @@ let make = (
2265
2536
  }
2266
2537
  })
2267
2538
 
2539
+ // Switch any contract already over the server-side address threshold to
2540
+ // client-side filtering at creation — a config contract with a large static
2541
+ // address list, or a dynamic contract restored from a large persisted set.
2542
+ switch clientFilterAddressThreshold {
2543
+ | Some(threshold) =>
2544
+ registeringContractsByContract->Utils.Dict.forEachWithKey((addrs, contractName) => {
2545
+ let addressCount = addrs->Utils.Dict.size
2546
+ if addressCount > threshold {
2547
+ clientFilteredContracts->addClientFilteredContract(
2548
+ ~contractName,
2549
+ ~chainId,
2550
+ ~addressCount,
2551
+ ~threshold,
2552
+ )
2553
+ }
2554
+ })
2555
+ | None => ()
2556
+ }
2557
+
2268
2558
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
2269
2559
  ~registeringContractsByContract,
2270
2560
  ~dynamicContracts,
2561
+ ~clientFilteredContracts,
2271
2562
  ~normalSelection,
2272
2563
  ~maxAddrInPartition,
2273
2564
  ~nextPartitionIndex=partitions->Array.length,
@@ -2325,6 +2616,7 @@ let make = (
2325
2616
  knownHeight,
2326
2617
  buffer,
2327
2618
  firstEventBlock,
2619
+ clientFilterAddressThreshold,
2328
2620
  }
2329
2621
 
2330
2622
  fetchState
@@ -2450,6 +2742,7 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2450
2742
  let optimizedPartitions = createPartitionsFromIndexingAddresses(
2451
2743
  ~registeringContractsByContract,
2452
2744
  ~dynamicContracts=fetchState.optimizedPartitions.dynamicContracts,
2745
+ ~clientFilteredContracts=fetchState.optimizedPartitions.clientFilteredContracts,
2453
2746
  ~normalSelection=fetchState.normalSelection,
2454
2747
  ~maxAddrInPartition=fetchState.optimizedPartitions.maxAddrInPartition,
2455
2748
  ~nextPartitionIndex=nextKeptIdRef.contents,