envio 3.5.0-alpha.0 → 3.5.0-alpha.2

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 (92) hide show
  1. package/index.d.ts +10 -6
  2. package/package.json +6 -6
  3. package/src/BatchProcessing.res +13 -0
  4. package/src/BatchProcessing.res.mjs +4 -0
  5. package/src/Bin.res +2 -2
  6. package/src/ChainFetching.res +5 -5
  7. package/src/ChainFetching.res.mjs +3 -4
  8. package/src/ChainState.res +44 -36
  9. package/src/ChainState.res.mjs +36 -31
  10. package/src/ChainState.resi +2 -12
  11. package/src/Change.res +3 -3
  12. package/src/Config.res +4 -4
  13. package/src/Config.res.mjs +1 -10
  14. package/src/Core.res +4 -35
  15. package/src/Core.res.mjs +1 -31
  16. package/src/CrossChainState.res +2 -20
  17. package/src/CrossChainState.res.mjs +2 -6
  18. package/src/EntityId.res +15 -0
  19. package/src/EntityId.res.mjs +9 -0
  20. package/src/Env.res +16 -0
  21. package/src/Env.res.mjs +6 -0
  22. package/src/EventConfigBuilder.res +1 -58
  23. package/src/EventConfigBuilder.res.mjs +1 -33
  24. package/src/FetchState.res +663 -484
  25. package/src/FetchState.res.mjs +491 -440
  26. package/src/HandlerRegister.res +45 -0
  27. package/src/HandlerRegister.res.mjs +43 -0
  28. package/src/InMemoryStore.res +11 -4
  29. package/src/InMemoryTable.res +12 -10
  30. package/src/InMemoryTable.res.mjs +6 -5
  31. package/src/IndexerState.res +59 -2
  32. package/src/IndexerState.res.mjs +48 -3
  33. package/src/IndexerState.resi +2 -0
  34. package/src/Internal.res +17 -8
  35. package/src/LogSelection.res +2 -1
  36. package/src/Metrics.res +37 -6
  37. package/src/Metrics.res.mjs +5 -1
  38. package/src/Persistence.res +1 -1
  39. package/src/PgStorage.res +55 -22
  40. package/src/PgStorage.res.mjs +25 -17
  41. package/src/SimulateItems.res +2 -2
  42. package/src/TestIndexer.res +6 -5
  43. package/src/TestIndexer.res.mjs +3 -2
  44. package/src/UserContext.res +3 -3
  45. package/src/Writing.res +12 -2
  46. package/src/Writing.res.mjs +13 -2
  47. package/src/bindings/ClickHouse.res +5 -3
  48. package/src/bindings/ClickHouse.res.mjs +2 -5
  49. package/src/bindings/Vitest.res +22 -1
  50. package/src/bindings/Vitest.res.mjs +15 -0
  51. package/src/db/EntityHistory.res +16 -7
  52. package/src/db/EntityHistory.res.mjs +7 -6
  53. package/src/db/Table.res +40 -9
  54. package/src/db/Table.res.mjs +44 -6
  55. package/src/sources/AddressSet.res +48 -0
  56. package/src/sources/AddressSet.res.mjs +11 -0
  57. package/src/sources/AddressStore.res +152 -0
  58. package/src/sources/AddressStore.res.mjs +97 -0
  59. package/src/sources/EvmChain.res +3 -0
  60. package/src/sources/EvmChain.res.mjs +4 -2
  61. package/src/sources/EvmHyperSyncSource.res +9 -9
  62. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  63. package/src/sources/EvmRpcClient.res +10 -3
  64. package/src/sources/EvmRpcClient.res.mjs +3 -3
  65. package/src/sources/FuelHyperSync.res +4 -3
  66. package/src/sources/FuelHyperSync.res.mjs +3 -3
  67. package/src/sources/FuelHyperSync.resi +2 -1
  68. package/src/sources/FuelHyperSyncClient.res +12 -6
  69. package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
  70. package/src/sources/FuelHyperSyncSource.res +7 -4
  71. package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
  72. package/src/sources/HyperSync.res +9 -5
  73. package/src/sources/HyperSync.res.mjs +3 -3
  74. package/src/sources/HyperSync.resi +3 -2
  75. package/src/sources/HyperSyncClient.res +24 -10
  76. package/src/sources/HyperSyncClient.res.mjs +4 -4
  77. package/src/sources/RpcSource.res +71 -67
  78. package/src/sources/RpcSource.res.mjs +4 -4
  79. package/src/sources/SimulateSource.res +26 -11
  80. package/src/sources/SimulateSource.res.mjs +13 -5
  81. package/src/sources/Source.res +8 -4
  82. package/src/sources/SourceManager.res +2 -3
  83. package/src/sources/SourceManager.res.mjs +2 -3
  84. package/src/sources/Svm.res +1 -2
  85. package/src/sources/Svm.res.mjs +1 -1
  86. package/src/sources/SvmHyperSyncClient.res +14 -4
  87. package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
  88. package/src/sources/SvmHyperSyncSource.res +11 -6
  89. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  90. package/src/IndexingAddresses.res +0 -108
  91. package/src/IndexingAddresses.res.mjs +0 -101
  92. package/src/IndexingAddresses.resi +0 -34
@@ -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; the client then
19
+ // gates each item against the chain's address store instead of the
20
+ // partition's set. 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
@@ -40,10 +49,13 @@ type partition = {
40
49
  // which added all its events to the queue
41
50
  latestFetchedBlock: blockNumberAndTimestamp,
42
51
  selection: selection,
43
- addressesByContractName: dict<array<Address.t>>,
52
+ // The partition's slice of the chain's address index. Ordered by
53
+ // (effectiveStartBlock, address) inside Rust, so a partition layout is a pure
54
+ // function of which addresses it holds — not of the order they arrived in.
55
+ addresses: AddressSet.t,
44
56
  mergeBlock: option<int>,
45
57
  // When set, partition indexes a single dynamic contract type.
46
- // The addressesByContractName must contain only addresses for this contract.
58
+ // `addresses` must then contain only addresses for this contract.
47
59
  dynamicContract: option<string>,
48
60
  // Mutated in place and shared across fetchState versions (updateInternal
49
61
  // copies the record, not this array): startFetchingQueries inserts,
@@ -72,44 +84,29 @@ type query = {
72
84
  fromBlock: int,
73
85
  toBlock: option<int>,
74
86
  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.
87
+ // Server-side maxNumLogs-style cap. Some only for open-ended probes, whose
88
+ // range isn't otherwise bounded; None for bounded chunk/gap-fill queries,
89
+ // whose toBlock already bounds the response so a cap would only self-truncate
90
+ // (worse under client-side address filtering, which counts filtered-out items
91
+ // toward the cap).
92
+ itemsTarget: option<int>,
93
+ // Density × the query's block range for a known-density partition, the
94
+ // query's budget share otherwise. This is the unit the chain's per-tick
95
+ // budget is reserved/consumed in and that sizes every query.
83
96
  itemsEst: int,
84
97
  selection: selection,
85
- addressesByContractName: dict<array<Address.t>>,
98
+ addresses: AddressSet.t,
86
99
  }
87
100
 
88
- // Invert addressesByContractName into address→contractName for log-ownership
89
- // routing. 1:1 today (each address belongs to one contract), so no key
90
- // collisions. Memoized on the addressesByContractName object so a partition's
91
- // many responses share one derivation and a large factory never rebuilds the
92
- // whole index; sound because the dict is immutable after construction (every
93
- // mutation produces a new dict).
94
- let deriveContractNameByAddress: dict<array<Address.t>> => dict<
95
- string,
96
- > = Utils.WeakMap.memoize(addressesByContractName => {
97
- let result = Dict.make()
98
- addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
99
- for i in 0 to addresses->Array.length - 1 {
100
- result->Dict.set(addresses->Array.getUnsafe(i)->Address.toString, contractName)
101
- }
102
- })
103
- result
104
- })
101
+ let withAddresses = (p: partition, addresses: AddressSet.t) => {...p, addresses}
105
102
 
106
- // itemsTarget for a query over [fromBlock, toBlock] at the given event density
103
+ // itemsEst for a query over [fromBlock, toBlock] at the given event density
107
104
  // (items/block). toBlock None is the open-ended tail, capped at
108
105
  // chainTargetBlock — the soft per-tick horizon the owning chain wants to reach
109
106
  // (see getNextQuery).
110
107
  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.
108
+ // Floor at 1: this is the budget reservation, and for a probe also its server
109
+ // cap a 0 cap would ask the backend for nothing.
113
110
  Pervasives.max(
114
111
  1,
115
112
  ((toBlock->Option.getOr(chainTargetBlock) - fromBlock + 1)->Int.toFloat *. density)
@@ -159,6 +156,12 @@ module OptimizedPartitions = {
159
156
  // Tracks all contract names that have been dynamically added.
160
157
  // Never reset - used to determine when to split existing partitions.
161
158
  dynamicContracts: Utils.Set.t<string>,
159
+ // Contract names switched to client-side address filtering once their
160
+ // registered address count crossed the server-side threshold. Sticky for
161
+ // the run: their addresses live only in the index, their events are fetched
162
+ // by the single address-free partition, and new dynamic addresses get a
163
+ // bounded backfill up to its frontier instead of a standing partition.
164
+ clientFilteredContracts: Utils.Set.t<string>,
162
165
  }
163
166
 
164
167
  @inline
@@ -184,10 +187,7 @@ module OptimizedPartitions = {
184
187
  ~maxAddrInPartition: int,
185
188
  ~nextPartitionIndexRef: ref<int>,
186
189
  ) => {
187
- let combinedAddresses =
188
- p1.addressesByContractName
189
- ->Dict.getUnsafe(contractName)
190
- ->Array.concat(p2.addressesByContractName->Dict.getUnsafe(contractName))
190
+ let combinedAddresses = p1.addresses->AddressSet.merge(p2.addresses)
191
191
 
192
192
  let p1Below = p1.latestFetchedBlock.blockNumber < potentialMergeBlock
193
193
  let p2Below = p2.latestFetchedBlock.blockNumber < potentialMergeBlock
@@ -224,7 +224,7 @@ module OptimizedPartitions = {
224
224
  selection: p1.selection,
225
225
  latestFetchedBlock: {blockNumber: potentialMergeBlock, blockTimestamp: 0},
226
226
  mergeBlock: None,
227
- addressesByContractName: Dict.make(), // set below
227
+ addresses: p1.addresses, // replaced below
228
228
  mutPendingQueries: [],
229
229
  sourceRangeCapacity: minRange,
230
230
  prevSourceRangeCapacity: minRange,
@@ -234,29 +234,28 @@ module OptimizedPartitions = {
234
234
  }
235
235
 
236
236
  // Apply address split on the continuing partition
237
- if combinedAddresses->Array.length > maxAddrInPartition {
238
- let addressesFull = combinedAddresses->Array.slice(~start=0, ~end=maxAddrInPartition)
239
- let addressesRest = combinedAddresses->Array.slice(~start=maxAddrInPartition)
240
- let abcFull = Dict.make()
241
- abcFull->Dict.set(contractName, addressesFull)
242
- let abcRest = Dict.make()
243
- abcRest->Dict.set(contractName, addressesRest)
244
- completed->Array.push({...continuingBase, addressesByContractName: abcFull})->ignore
237
+ if combinedAddresses->AddressSet.size > maxAddrInPartition {
238
+ completed
239
+ ->Array.push(
240
+ continuingBase->withAddresses(
241
+ combinedAddresses->AddressSet.slice(~offset=0, ~limit=Some(maxAddrInPartition)),
242
+ ),
243
+ )
244
+ ->ignore
245
245
  let restId = nextPartitionIndexRef.contents->Int.toString
246
246
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
247
247
  completed
248
248
  ->Array.push({
249
- ...continuingBase,
249
+ ...continuingBase->withAddresses(
250
+ combinedAddresses->AddressSet.slice(~offset=maxAddrInPartition, ~limit=None),
251
+ ),
250
252
  id: restId,
251
- addressesByContractName: abcRest,
252
253
  mutPendingQueries: [],
253
254
  })
254
255
  ->ignore
255
256
  completed
256
257
  } else {
257
- let abc = Dict.make()
258
- abc->Dict.set(contractName, combinedAddresses)
259
- completed->Array.push({...continuingBase, addressesByContractName: abc})->ignore
258
+ completed->Array.push(continuingBase->withAddresses(combinedAddresses))->ignore
260
259
  completed
261
260
  }
262
261
  }
@@ -283,6 +282,7 @@ module OptimizedPartitions = {
283
282
  ~maxAddrInPartition,
284
283
  ~nextPartitionIndex: int,
285
284
  ~dynamicContracts: Utils.Set.t<string>,
285
+ ~clientFilteredContracts: Utils.Set.t<string>,
286
286
  ) => {
287
287
  let newPartitions = []
288
288
  let mergingPartitions = Dict.make()
@@ -303,7 +303,7 @@ module OptimizedPartitions = {
303
303
  {mergeBlock: Some(_)} =>
304
304
  newPartitions->Array.push(p)->ignore
305
305
  | {dynamicContract: Some(contractName)} =>
306
- let pAddressesCount = p.addressesByContractName->Dict.getUnsafe(contractName)->Array.length
306
+ let pAddressesCount = p.addresses->AddressSet.countFor(contractName)
307
307
  // Compute merge block: last pending query's toBlock, or lfb if idle
308
308
  let potentialMergeBlock = switch p.mutPendingQueries->Utils.Array.last {
309
309
  | Some({isChunk: true, toBlock: Some(toBlock)}) => Some(toBlock)
@@ -416,6 +416,7 @@ module OptimizedPartitions = {
416
416
  maxAddrInPartition,
417
417
  nextPartitionIndex: nextPartitionIndexRef.contents,
418
418
  dynamicContracts,
419
+ clientFilteredContracts,
419
420
  }
420
421
  }
421
422
 
@@ -467,14 +468,37 @@ module OptimizedPartitions = {
467
468
  }
468
469
  }
469
470
 
470
- let handleQueryResponse = (
471
+ let rec handleQueryResponse = (
472
+ optimizedPartitions: t,
473
+ ~query,
474
+ ~knownHeight,
475
+ ~itemsCount,
476
+ ~latestFetchedBlock: blockNumberAndTimestamp,
477
+ ) =>
478
+ switch optimizedPartitions.entities->Utils.Dict.dangerouslyGetNonOption(query.partitionId) {
479
+ // The partition was absorbed into the address-free client-filtered partition
480
+ // while this query was in flight. Its items are still merged into the buffer
481
+ // by the caller (and deduped); there's no partition bookkeeping left to do,
482
+ // and its reservation is released by ChainState regardless.
483
+ | None => optimizedPartitions
484
+ | Some(p) =>
485
+ optimizedPartitions->handleQueryResponseForPartition(
486
+ ~p,
487
+ ~query,
488
+ ~knownHeight,
489
+ ~itemsCount,
490
+ ~latestFetchedBlock,
491
+ )
492
+ }
493
+
494
+ and handleQueryResponseForPartition = (
471
495
  optimizedPartitions: t,
496
+ ~p: partition,
472
497
  ~query,
473
498
  ~knownHeight,
474
499
  ~itemsCount,
475
500
  ~latestFetchedBlock: blockNumberAndTimestamp,
476
501
  ) => {
477
- let p = optimizedPartitions->getOrThrow(~partitionId=query.partitionId)
478
502
  let mutEntities = optimizedPartitions.entities->Utils.Dict.shallowCopy
479
503
 
480
504
  // Mark query as fetched
@@ -508,8 +532,12 @@ module OptimizedPartitions = {
508
532
  if latestFetchedBlock.blockNumber < queryToBlock {
509
533
  // Partial response is direct capacity evidence — unless it was
510
534
  // truncated by our own itemsTarget cap: that reflects the
511
- // reservation we asked for, not what the server could return.
512
- itemsCount < query.itemsTarget
535
+ // reservation we asked for, not what the server could return. A
536
+ // capless bounded query can only have been truncated by the source.
537
+ switch query.itemsTarget {
538
+ | None => true
539
+ | Some(itemsTarget) => itemsCount < itemsTarget
540
+ }
513
541
  } else {
514
542
  // A full response updates only when the query's intended range
515
543
  // covers at least the partition's current chunk range — meaning it
@@ -549,6 +577,7 @@ module OptimizedPartitions = {
549
577
  ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
550
578
  ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
551
579
  ~dynamicContracts=optimizedPartitions.dynamicContracts,
580
+ ~clientFilteredContracts=optimizedPartitions.clientFilteredContracts,
552
581
  )
553
582
  } else {
554
583
  let updatedMainPartition = {
@@ -598,6 +627,7 @@ module OptimizedPartitions = {
598
627
  ~maxAddrInPartition=optimizedPartitions.maxAddrInPartition,
599
628
  ~nextPartitionIndex=optimizedPartitions.nextPartitionIndex,
600
629
  ~dynamicContracts=optimizedPartitions.dynamicContracts,
630
+ ~clientFilteredContracts=optimizedPartitions.clientFilteredContracts,
601
631
  )
602
632
  }
603
633
  }
@@ -617,8 +647,6 @@ type t = {
617
647
  startBlock: int,
618
648
  endBlock: option<int>,
619
649
  normalSelection: selection,
620
- // By contract name
621
- contractConfigs: dict<IndexingAddresses.contractConfig>,
622
650
  // Not used for logic - only metadata
623
651
  chainId: int,
624
652
  // The block number of the latest block which was added to the queue
@@ -638,6 +666,10 @@ type t = {
638
666
  onBlockRegistrations: array<Internal.onBlockRegistration>,
639
667
  knownHeight: int,
640
668
  firstEventBlock: option<int>,
669
+ // Per-contract registered-address count past which a dynamic contract is
670
+ // switched to client-side filtering. None disables the switch, leaving every
671
+ // contract filtered server-side.
672
+ clientFilterAddressThreshold: option<int>,
641
673
  }
642
674
 
643
675
  @inline
@@ -892,7 +924,6 @@ let updateInternal = (
892
924
  let updatedFetchState = {
893
925
  startBlock: fetchState.startBlock,
894
926
  endBlock: fetchState.endBlock,
895
- contractConfigs: fetchState.contractConfigs,
896
927
  normalSelection: fetchState.normalSelection,
897
928
  chainId: fetchState.chainId,
898
929
  onBlockRegistrations: fetchState.onBlockRegistrations,
@@ -907,43 +938,238 @@ let updateInternal = (
907
938
  | blockItems => base->mergeIntoBuffer(blockItems)
908
939
  },
909
940
  firstEventBlock: fetchState.firstEventBlock,
941
+ clientFilterAddressThreshold: fetchState.clientFilterAddressThreshold,
910
942
  }
911
943
 
912
944
  updatedFetchState
913
945
  }
914
946
 
915
- let warnDifferentContractType = (
916
- fetchState,
917
- ~existingContract: indexingAddress,
918
- ~dc: indexingAddress,
947
+ // Move a contract to client-side address filtering, recording why.
948
+ let addClientFilteredContract = (
949
+ clientFilteredContracts: Utils.Set.t<string>,
950
+ ~contractName,
951
+ ~chainId,
952
+ ~addressCount,
953
+ ~threshold,
919
954
  ) => {
920
- let logger = Logging.createChild(
955
+ clientFilteredContracts->Utils.Set.add(contractName)->ignore
956
+ Logging.createChild(
921
957
  ~params={
922
- "chainId": fetchState.chainId,
923
- "contractAddress": dc.address->Address.toString,
924
- "existingContractType": existingContract.contractName,
925
- "newContractType": dc.contractName,
958
+ "chainId": chainId,
959
+ "contractName": contractName,
960
+ "addressCount": addressCount,
961
+ "threshold": threshold,
926
962
  },
963
+ )->Logging.childTrace(
964
+ "Switching contract to client-side address filtering: registered address count crossed the server-side threshold.",
927
965
  )
928
- logger->Logging.childWarn(`Skipping contract registration: Contract address is already registered for one contract and cannot be registered for another contract.`)
929
966
  }
930
967
 
931
- let addressesByContractNameCount = (addressesByContractName: dict<array<Address.t>>) => {
932
- let numAddresses = ref(0)
933
- addressesByContractName->Utils.Dict.forEach(addresses => {
934
- numAddresses := numAddresses.contents + addresses->Array.length
935
- })
936
- numAddresses.contents
937
- }
968
+ // The block a partition will have fetched to once everything on its pending
969
+ // queue lands — not the block it has fetched to now. A backfill has to reach it
970
+ // rather than the frontier: a query that was routed before this batch's
971
+ // addresses reached the address store carries no events for them, yet it still
972
+ // advances the frontier over its range on arrival, and nothing else would ever
973
+ // refetch it. An in-flight open-ended query has no toBlock of its own; it can't
974
+ // return past the chain's known height, so that bounds it.
975
+ let claimedFetchedBlock = (p: partition, ~knownHeight) =>
976
+ p.mutPendingQueries->Array.reduce(p.latestFetchedBlock.blockNumber, (max, q) =>
977
+ Pervasives.max(
978
+ max,
979
+ switch q.fetchedBlock {
980
+ | Some({blockNumber}) => blockNumber
981
+ | None => q.toBlock->Option.getOr(knownHeight)
982
+ },
983
+ )
984
+ )
985
+
986
+ // Fold every client-filtered contract's server-side partitions into client-side
987
+ // fetching without tearing down established state:
988
+ // - The standing address-free partition (no mergeBlock) keeps its id, frontier,
989
+ // in-flight queries and learned density. Only when a newly-switched contract
990
+ // must be added does its selection change — under a fresh id, so in-flight
991
+ // responses built from the old selection are orphaned instead of advancing
992
+ // the frontier past ranges the new contract wasn't fetched for.
993
+ // - Partitions absorbed below the standing partition's claimed block
994
+ // (single-contract dynamic partitions and config partitions all of whose
995
+ // contracts are client-filtered, plus any prior backfill) become one bounded
996
+ // backfill partition covering [their min frontier, that claimed block]:
997
+ // getNextQuery caps its queries at mergeBlock and handleQueryResponse deletes
998
+ // it on arrival. The overlap it re-delivers is deduped by mergeIntoBuffer, and
999
+ // the re-fetch doubles as history for freshly registered addresses: events
1000
+ // dropped before the address was registered now pass the address gate.
1001
+ // - A partition mixing client-filtered and server-side contracts stays,
1002
+ // stripped of the client-filtered contracts' addresses — the address-free
1003
+ // partition covers those logs, so fetching them server-side too would only
1004
+ // produce duplicates. Its frontier bounds the backfill from below so the
1005
+ // stripped contracts lose no range.
1006
+ //
1007
+ // Registrations come from the address-free partitions plus `normalSelection`
1008
+ // filtered to client-filtered contracts, so coverage never depends on which
1009
+ // partitions happened to be absorbed (a stripped contract may have no
1010
+ // absorbable partition at all).
1011
+ let collapseClientFilteredContracts = (
1012
+ partitions: array<partition>,
1013
+ ~clientFilteredContracts: Utils.Set.t<string>,
1014
+ ~normalSelection: selection,
1015
+ ~nextPartitionIndexRef: ref<int>,
1016
+ ~addressStore: AddressStore.t,
1017
+ ~knownHeight: int,
1018
+ ) => {
1019
+ if clientFilteredContracts->Utils.Set.size === 0 {
1020
+ partitions
1021
+ } else {
1022
+ let kept = []
1023
+ let standingRef = ref(None)
1024
+ let backfills = []
1025
+ let absorbedPartitions = []
1026
+ let strippedFrontiers = []
1027
+
1028
+ partitions->Array.forEach(p =>
1029
+ switch p {
1030
+ | {selection: {dependsOnAddresses: false}, mergeBlock: None}
1031
+ if standingRef.contents->Option.isNone =>
1032
+ standingRef := Some(p)
1033
+ | {selection: {dependsOnAddresses: false}, mergeBlock: Some(_)} =>
1034
+ backfills->Array.push(p)->ignore
1035
+ | {selection: {dependsOnAddresses: false}} => absorbedPartitions->Array.push(p)->ignore
1036
+ | _ =>
1037
+ let contractNames = p.addresses->AddressSet.contractNames
1038
+ let serverSideNames =
1039
+ contractNames->Array.filter(c => !(clientFilteredContracts->Utils.Set.has(c)))
1040
+ if serverSideNames->Array.length === contractNames->Array.length {
1041
+ kept->Array.push(p)->ignore
1042
+ } else if serverSideNames->Utils.Array.isEmpty {
1043
+ absorbedPartitions->Array.push(p)->ignore
1044
+ } else {
1045
+ strippedFrontiers->Array.push(p.latestFetchedBlock)->ignore
1046
+ kept
1047
+ ->Array.push(p->withAddresses(p.addresses->AddressSet.filterByContracts(serverSideNames)))
1048
+ ->ignore
1049
+ }
1050
+ }
1051
+ )
938
1052
 
939
- let addressesByContractNameGetAll = (addressesByContractName: dict<array<Address.t>>) => {
940
- let all = []
941
- addressesByContractName->Utils.Dict.forEach(addresses => {
942
- for idx in 0 to addresses->Array.length - 1 {
943
- all->Array.push(addresses->Array.getUnsafe(idx))->ignore
1053
+ let selectionChanged = switch standingRef.contents {
1054
+ | Some(standing) =>
1055
+ standing.selection.clientFilteredContracts->Option.mapOr(0, Array.length) !==
1056
+ clientFilteredContracts->Utils.Set.size
1057
+ | None => true
944
1058
  }
945
- })
946
- all
1059
+
1060
+ if (
1061
+ absorbedPartitions->Utils.Array.isEmpty &&
1062
+ strippedFrontiers->Utils.Array.isEmpty &&
1063
+ !selectionChanged
1064
+ ) {
1065
+ // Nothing to fold in and no newly-switched contract: leave the standing
1066
+ // partition (and any in-progress backfill) untouched.
1067
+ partitions
1068
+ } else {
1069
+ let minFrontierRef: ref<option<blockNumberAndTimestamp>> = ref(None)
1070
+ let considerFrontier = (b: blockNumberAndTimestamp) =>
1071
+ switch minFrontierRef.contents {
1072
+ | Some(m) if m.blockNumber <= b.blockNumber => ()
1073
+ | _ => minFrontierRef := Some(b)
1074
+ }
1075
+ absorbedPartitions->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1076
+ backfills->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1077
+ strippedFrontiers->Array.forEach(considerFrontier)
1078
+
1079
+ let regByIndex = Dict.make()
1080
+ let addRegs = (regs: array<Internal.onEventRegistration>) =>
1081
+ regs->Array.forEach(reg => regByIndex->Dict.set(reg.index->Int.toString, reg))
1082
+ switch standingRef.contents {
1083
+ | Some(standing) => addRegs(standing.selection.onEventRegistrations)
1084
+ | None => ()
1085
+ }
1086
+ absorbedPartitions->Array.forEach(p =>
1087
+ if !p.selection.dependsOnAddresses {
1088
+ addRegs(p.selection.onEventRegistrations)
1089
+ }
1090
+ )
1091
+ addRegs(
1092
+ normalSelection.onEventRegistrations->Array.filter(reg =>
1093
+ clientFilteredContracts->Utils.Set.has(reg.eventConfig.contractName)
1094
+ ),
1095
+ )
1096
+ let newSelection = {
1097
+ dependsOnAddresses: false,
1098
+ onEventRegistrations: regByIndex->Dict.valuesToArray,
1099
+ clientFilteredContracts: clientFilteredContracts->Utils.Set.toArray,
1100
+ }
1101
+
1102
+ switch standingRef.contents {
1103
+ | None =>
1104
+ // First switch: no standing partition to preserve, so create it at the
1105
+ // min frontier directly — the whole range above is unfetched for the
1106
+ // client-filtered side anyway.
1107
+ switch minFrontierRef.contents {
1108
+ | None => kept
1109
+ | Some(minFrontier) =>
1110
+ let minRange = getMinQueryRange(absorbedPartitions)
1111
+ let id = nextPartitionIndexRef.contents->Int.toString
1112
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1113
+ kept
1114
+ ->Array.push({
1115
+ id,
1116
+ latestFetchedBlock: minFrontier,
1117
+ selection: newSelection,
1118
+ addresses: addressStore->AddressStore.emptySet,
1119
+ mergeBlock: None,
1120
+ dynamicContract: None,
1121
+ mutPendingQueries: [],
1122
+ sourceRangeCapacity: minRange,
1123
+ prevSourceRangeCapacity: minRange,
1124
+ eventDensity: None,
1125
+ latestSourceRangeCapacityUpdateBlock: 0,
1126
+ })
1127
+ ->ignore
1128
+ kept
1129
+ }
1130
+ | Some(standing) =>
1131
+ let standingOut = if selectionChanged {
1132
+ let id = nextPartitionIndexRef.contents->Int.toString
1133
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1134
+ {...standing, id, selection: newSelection, mutPendingQueries: []}
1135
+ } else {
1136
+ standing
1137
+ }
1138
+ kept->Array.push(standingOut)->ignore
1139
+ // Read off standingOut, not standing: a selection change orphans the
1140
+ // in-flight queries (fresh id, empty queue), so there the frontier is
1141
+ // all the partition will ever claim.
1142
+ let catchUpToBlock = standingOut->claimedFetchedBlock(~knownHeight)
1143
+ switch minFrontierRef.contents {
1144
+ | Some(minFrontier) if minFrontier.blockNumber < catchUpToBlock =>
1145
+ let id = nextPartitionIndexRef.contents->Int.toString
1146
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1147
+ kept
1148
+ ->Array.push({
1149
+ id,
1150
+ latestFetchedBlock: minFrontier,
1151
+ selection: newSelection,
1152
+ addresses: addressStore->AddressStore.emptySet,
1153
+ mergeBlock: Some(catchUpToBlock),
1154
+ dynamicContract: None,
1155
+ mutPendingQueries: [],
1156
+ // Same query shape as the standing partition, so inherit its
1157
+ // learned range and density instead of probing from scratch.
1158
+ sourceRangeCapacity: standing.sourceRangeCapacity,
1159
+ prevSourceRangeCapacity: standing.prevSourceRangeCapacity,
1160
+ eventDensity: standing.eventDensity,
1161
+ latestSourceRangeCapacityUpdateBlock: 0,
1162
+ })
1163
+ ->ignore
1164
+ // An absorbed frontier at/above the claimed block needs no backfill:
1165
+ // nothing has fetched past there yet, so the standing partition still
1166
+ // covers it going forward.
1167
+ | _ => ()
1168
+ }
1169
+ kept
1170
+ }
1171
+ }
1172
+ }
947
1173
  }
948
1174
 
949
1175
  /**
@@ -953,14 +1179,19 @@ Phase 2: Merge non-dynamic partitions together to reduce unnecessary concurrency
953
1179
  Returns OptimizedPartitions.t directly.
954
1180
  (Dynamic partitions are merged by OptimizedPartitions.make automatically)
955
1181
  */
956
- let createPartitionsFromIndexingAddresses = (
957
- ~registeringContractsByContract: dict<dict<indexingAddress>>,
1182
+ let createPartitions = (
1183
+ // One set per contract, holding the addresses that still need a partition:
1184
+ // a batch's fresh registrations, or the survivors of a rolled-back partition.
1185
+ ~registeringSetsByContract: dict<AddressSet.t>,
1186
+ ~addressStore: AddressStore.t,
958
1187
  ~dynamicContracts: Utils.Set.t<string>,
1188
+ ~clientFilteredContracts: Utils.Set.t<string>,
959
1189
  ~normalSelection: selection,
960
1190
  ~maxAddrInPartition: int,
961
1191
  ~nextPartitionIndex: int,
962
1192
  ~existingPartitions: array<partition>,
963
1193
  ~progressBlockNumber: int,
1194
+ ~knownHeight: int,
964
1195
  ): // Floor for latestFetchedBlock (use progressBlockNumber from make, or 0 for registerDynamicContracts)
965
1196
  OptimizedPartitions.t => {
966
1197
  let nextPartitionIndexRef = ref(nextPartitionIndex)
@@ -969,90 +1200,67 @@ OptimizedPartitions.t => {
969
1200
  let dynamicPartitions = []
970
1201
  let nonDynamicPartitions = []
971
1202
 
972
- let contractNames = registeringContractsByContract->Dict.keysToArray
1203
+ let contractNames = registeringSetsByContract->Dict.keysToArray
973
1204
  for cIdx in 0 to contractNames->Array.length - 1 {
974
1205
  let contractName = contractNames->Array.getUnsafe(cIdx)
975
- let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
976
- let addresses =
977
- registeringContracts->Dict.keysToArray->(Utils.magic: array<string> => array<Address.t>)
1206
+ let contractSet = registeringSetsByContract->Dict.getUnsafe(contractName)
978
1207
 
979
1208
  let isDynamic = dynamicContracts->Utils.Set.has(contractName)
980
1209
  let partitions = isDynamic ? dynamicPartitions : nonDynamicPartitions
981
1210
 
982
- let byStartBlock = Dict.make()
983
- for jdx in 0 to addresses->Array.length - 1 {
984
- let address = addresses->Array.getUnsafe(jdx)
985
- let indexingContract = registeringContracts->Dict.getUnsafe(address->Address.toString)
986
- byStartBlock->Utils.Dict.push(indexingContract.effectiveStartBlock->Int.toString, address)
987
- }
988
-
989
- // Will be in ASC order by JS spec
990
- let ascKeys = byStartBlock->Dict.keysToArray
991
- let initialKey = ascKeys->Utils.Array.firstUnsafe
992
-
993
- let startBlockRef = ref(initialKey->Int.fromString->Option.getUnsafe)
994
- let addressesRef = ref(byStartBlock->Dict.getUnsafe(initialKey))
995
-
996
- for idx in 0 to ascKeys->Array.length - 1 {
997
- let maybeNextStartBlockKey =
998
- ascKeys->Array.getUnsafe(idx + 1)->(Utils.magic: string => option<string>)
999
-
1000
- let shouldAllocateNewPartition = switch maybeNextStartBlockKey {
1001
- | None => true
1002
- | Some(nextStartBlockKey) => {
1003
- let nextStartBlock = nextStartBlockKey->Int.fromString->Option.getUnsafe
1004
- let shouldJoinCurrentStartBlock =
1005
- nextStartBlock - startBlockRef.contents < OptimizedPartitions.tooFarBlockRange
1006
-
1007
- // Addresses with different start blocks within range share a partition;
1008
- // events before each address's effectiveStartBlock are dropped on the
1009
- // client side (the event's clientAddressFilter for address-valued
1010
- // params).
1011
- if shouldJoinCurrentStartBlock {
1012
- addressesRef :=
1013
- addressesRef.contents->Array.concat(byStartBlock->Dict.getUnsafe(nextStartBlockKey))
1014
- false
1015
- } else {
1016
- true
1017
- }
1211
+ // A set is ordered by effectiveStartBlock, so its start-block groups are
1212
+ // ascending and each group's addresses are a contiguous slice.
1213
+ let groups = contractSet->AddressSet.startBlockGroups
1214
+ let offsetRef = ref(0)
1215
+ let groupIdx = ref(0)
1216
+ while groupIdx.contents < groups->Array.length {
1217
+ let startBlock = (groups->Array.getUnsafe(groupIdx.contents)).startBlock
1218
+ // Addresses with different start blocks within range share a partition;
1219
+ // events before each address's effectiveStartBlock are dropped by the
1220
+ // source's address gate.
1221
+ let countRef = ref(0)
1222
+ let nextIdx = ref(groupIdx.contents)
1223
+ let joining = ref(true)
1224
+ while joining.contents && nextIdx.contents < groups->Array.length {
1225
+ let group = groups->Array.getUnsafe(nextIdx.contents)
1226
+ if group.startBlock - startBlock < OptimizedPartitions.tooFarBlockRange {
1227
+ countRef := countRef.contents + group.count
1228
+ nextIdx := nextIdx.contents + 1
1229
+ } else {
1230
+ joining := false
1018
1231
  }
1019
1232
  }
1020
1233
 
1021
- if shouldAllocateNewPartition {
1022
- let latestFetchedBlock = {
1023
- blockNumber: Pervasives.max(startBlockRef.contents - 1, progressBlockNumber),
1024
- blockTimestamp: 0,
1025
- }
1026
- while addressesRef.contents->Array.length > 0 {
1027
- let pAddresses = addressesRef.contents->Array.slice(~start=0, ~end=maxAddrInPartition)
1028
- addressesRef.contents = addressesRef.contents->Array.slice(~start=maxAddrInPartition)
1029
-
1030
- let addressesByContractName = Dict.make()
1031
- addressesByContractName->Dict.set(contractName, pAddresses)
1032
- partitions->Array.push({
1033
- id: nextPartitionIndexRef.contents->Int.toString,
1034
- latestFetchedBlock,
1035
- selection: normalSelection,
1036
- dynamicContract: isDynamic ? Some(contractName) : None,
1037
- addressesByContractName,
1038
- mergeBlock: None,
1039
- mutPendingQueries: [],
1040
- sourceRangeCapacity: 0,
1041
- prevSourceRangeCapacity: 0,
1042
- eventDensity: None,
1043
- latestSourceRangeCapacityUpdateBlock: 0,
1044
- })
1045
- nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1046
- }
1047
-
1048
- switch maybeNextStartBlockKey {
1049
- | None => ()
1050
- | Some(nextStartBlockKey) => {
1051
- startBlockRef := nextStartBlockKey->Int.fromString->Option.getUnsafe
1052
- addressesRef := byStartBlock->Dict.getUnsafe(nextStartBlockKey)
1053
- }
1054
- }
1234
+ let latestFetchedBlock = {
1235
+ blockNumber: Pervasives.max(startBlock - 1, progressBlockNumber),
1236
+ blockTimestamp: 0,
1237
+ }
1238
+ let remainingRef = ref(countRef.contents)
1239
+ let chunkOffsetRef = ref(offsetRef.contents)
1240
+ while remainingRef.contents > 0 {
1241
+ let take = Pervasives.min(remainingRef.contents, maxAddrInPartition)
1242
+ let pAddresses =
1243
+ contractSet->AddressSet.slice(~offset=chunkOffsetRef.contents, ~limit=Some(take))
1244
+ partitions->Array.push({
1245
+ id: nextPartitionIndexRef.contents->Int.toString,
1246
+ latestFetchedBlock,
1247
+ selection: normalSelection,
1248
+ dynamicContract: isDynamic ? Some(contractName) : None,
1249
+ addresses: pAddresses,
1250
+ mergeBlock: None,
1251
+ mutPendingQueries: [],
1252
+ sourceRangeCapacity: 0,
1253
+ prevSourceRangeCapacity: 0,
1254
+ eventDensity: None,
1255
+ latestSourceRangeCapacityUpdateBlock: 0,
1256
+ })
1257
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1258
+ chunkOffsetRef := chunkOffsetRef.contents + take
1259
+ remainingRef := remainingRef.contents - take
1055
1260
  }
1261
+
1262
+ offsetRef := offsetRef.contents + countRef.contents
1263
+ groupIdx := nextIdx.contents
1056
1264
  }
1057
1265
  }
1058
1266
 
@@ -1072,29 +1280,14 @@ OptimizedPartitions.t => {
1072
1280
  let currentPBlock = currentP.latestFetchedBlock.blockNumber
1073
1281
  let nextPBlock = nextP.latestFetchedBlock.blockNumber
1074
1282
 
1075
- // Compute total count WITHOUT mutating any arrays
1076
- let totalCount =
1077
- currentP.addressesByContractName->addressesByContractNameCount +
1078
- nextP.addressesByContractName->addressesByContractNameCount
1283
+ let totalCount = currentP.addresses->AddressSet.size + nextP.addresses->AddressSet.size
1079
1284
 
1080
1285
  if totalCount > maxAddrInPartition {
1081
1286
  // Exceeds address limit - don't merge, keep partitions separate
1082
1287
  mergedNonDynamic->Array.push(currentP)->ignore
1083
1288
  currentPRef := nextP
1084
1289
  } else {
1085
- // Build merged addresses using Array.concat (non-mutating)
1086
- let mergedAddresses = nextP.addressesByContractName->Utils.Dict.shallowCopy
1087
- let currentContractNames = currentP.addressesByContractName->Dict.keysToArray
1088
- for jdx in 0 to currentContractNames->Array.length - 1 {
1089
- let cn = currentContractNames->Array.getUnsafe(jdx)
1090
- let currentAddrs = currentP.addressesByContractName->Dict.getUnsafe(cn)
1091
- switch mergedAddresses->Utils.Dict.dangerouslyGetNonOption(cn) {
1092
- | Some(existingAddrs) =>
1093
- // Use concat (non-mutating) to avoid corrupting nextP's arrays
1094
- mergedAddresses->Dict.set(cn, existingAddrs->Array.concat(currentAddrs))
1095
- | None => mergedAddresses->Dict.set(cn, currentAddrs)
1096
- }
1097
- }
1290
+ let mergedAddresses = nextP.addresses->AddressSet.merge(currentP.addresses)
1098
1291
 
1099
1292
  let isTooFar = currentPBlock + OptimizedPartitions.tooFarBlockRange < nextPBlock
1100
1293
 
@@ -1106,16 +1299,10 @@ OptimizedPartitions.t => {
1106
1299
  mergeBlock: currentPBlock < nextPBlock ? Some(nextPBlock) : None,
1107
1300
  })
1108
1301
  ->ignore
1109
- currentPRef := {
1110
- ...nextP,
1111
- addressesByContractName: mergedAddresses,
1112
- }
1302
+ currentPRef := nextP->withAddresses(mergedAddresses)
1113
1303
  } else {
1114
1304
  // Close: push next's addresses into current
1115
- currentPRef := {
1116
- ...currentP,
1117
- addressesByContractName: mergedAddresses,
1118
- }
1305
+ currentPRef := currentP->withAddresses(mergedAddresses)
1119
1306
  }
1120
1307
  }
1121
1308
 
@@ -1127,18 +1314,37 @@ OptimizedPartitions.t => {
1127
1314
 
1128
1315
  let mergedPartitions = mergedNonDynamic->Array.concat(dynamicPartitions)
1129
1316
 
1130
- // Final step: concat existing partitions with phase 1+2 result and call OptimizedPartitions.make
1317
+ // Final step: concat existing partitions with phase 1+2 result, collapse any
1318
+ // client-filtered contracts into the single address-free partition, and optimize.
1319
+ let allPartitions =
1320
+ existingPartitions
1321
+ ->Array.concat(mergedPartitions)
1322
+ ->collapseClientFilteredContracts(
1323
+ ~clientFilteredContracts,
1324
+ ~normalSelection,
1325
+ ~nextPartitionIndexRef,
1326
+ ~addressStore,
1327
+ ~knownHeight,
1328
+ )
1131
1329
  OptimizedPartitions.make(
1132
- ~partitions=existingPartitions->Array.concat(mergedPartitions),
1330
+ ~partitions=allPartitions,
1133
1331
  ~maxAddrInPartition,
1134
1332
  ~nextPartitionIndex=nextPartitionIndexRef.contents,
1135
1333
  ~dynamicContracts,
1334
+ ~clientFilteredContracts,
1136
1335
  )
1137
1336
  }
1138
1337
 
1139
1338
  let registerDynamicContracts = (
1140
1339
  fetchState: t,
1141
- ~indexingAddresses: IndexingAddresses.t,
1340
+ ~addressStore: AddressStore.t,
1341
+ // How far an in-flight open-ended query may end up claiming. These
1342
+ // registrations usually come out of a response that is applied right after
1343
+ // this call, and an unbounded query can reach past the height known when it
1344
+ // was dispatched — so the caller folds that response's own frontier and
1345
+ // height in here. Defaults to what the fetch state knows, which is all
1346
+ // there is to go on when no response is being applied.
1347
+ ~claimCeiling=fetchState.knownHeight,
1142
1348
  // These are raw items which might have dynamic contracts received from contractRegister call.
1143
1349
  // Might contain duplicates which we should filter out
1144
1350
  items: array<Internal.item>,
@@ -1150,167 +1356,130 @@ let registerDynamicContracts = (
1150
1356
  )
1151
1357
  }
1152
1358
 
1153
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
1154
- let earliestRegisteringEventBlockNumber = ref(%raw(`Infinity`))
1155
- // Addresses registered for contracts without matching events. These are not
1156
- // added to partitions, but they are tracked on indexingAddresses
1157
- // so that later conflicting registrations are detected, and are persisted
1158
- // to envio_addresses so they can be picked up on restart with updated config.
1159
- let noEventsAddresses: dict<indexingAddress> = Dict.make()
1160
- // Batch-level view of all addresses registered so far (across contracts,
1161
- // including no-events ones), so two contracts registering the same address
1162
- // within one batch conflict the same way as against indexingAddresses.
1163
- let registeringAddresses: dict<indexingAddress> = Dict.make()
1164
-
1359
+ // Flatten every item's dcs into one batch, remembering where each came from
1360
+ // so the rejected ones can be spliced back out of their item (which is what
1361
+ // keeps them from being persisted twice).
1362
+ let registrations: array<AddressStore.registration> = []
1363
+ let sourceDcs: array<Internal.dcs> = []
1364
+ let sourceIndexes: array<int> = []
1165
1365
  for itemIdx in 0 to items->Array.length - 1 {
1166
- let item = items->Array.getUnsafe(itemIdx)
1167
- switch item->Internal.getItemDcs {
1366
+ switch items->Array.getUnsafe(itemIdx)->Internal.getItemDcs {
1168
1367
  | None => ()
1169
1368
  | Some(dcs) =>
1170
- let idx = ref(0)
1171
- while idx.contents < dcs->Array.length {
1172
- let dc = dcs->Array.getUnsafe(idx.contents)
1173
-
1174
- let shouldRemove = ref(false)
1175
-
1176
- switch fetchState.contractConfigs->Utils.Dict.dangerouslyGetNonOption(dc.contractName) {
1177
- | Some({startBlock: contractStartBlock}) =>
1178
- let dcWithStartBlock: indexingAddress = {
1179
- address: dc.address,
1180
- contractName: dc.contractName,
1181
- registrationBlock: dc.registrationBlock,
1182
- effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1183
- ~registrationBlock=dc.registrationBlock,
1184
- ~contractStartBlock,
1185
- ),
1186
- }
1187
- // Prevent registering already indexing contracts
1188
- switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1189
- | Some(existingContract) =>
1190
- // FIXME: Instead of filtering out duplicates,
1191
- // we should check the block number first.
1192
- // If new registration with earlier block number
1193
- // we should register it for the missing block range
1194
- if existingContract.contractName != dc.contractName {
1195
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcWithStartBlock)
1196
- } else if existingContract.effectiveStartBlock > dcWithStartBlock.effectiveStartBlock {
1197
- let logger = Logging.createChild(
1198
- ~params={
1199
- "chainId": fetchState.chainId,
1200
- "contractAddress": dc.address->Address.toString,
1201
- "existingBlockNumber": existingContract.effectiveStartBlock,
1202
- "newBlockNumber": dcWithStartBlock.effectiveStartBlock,
1203
- },
1204
- )
1205
- logger->Logging.childWarn(`Skipping contract registration: Contract address is already registered at a later block number. Currently registration of the same contract address is not supported by Envio. Reach out to us if it's a problem for you.`)
1206
- }
1207
- shouldRemove := true
1208
- | None =>
1209
- let shouldUpdate = switch registeringAddresses->Utils.Dict.dangerouslyGetNonOption(
1210
- dc.address->Address.toString,
1211
- ) {
1212
- | Some(registeringContract) if registeringContract.contractName != dc.contractName =>
1213
- fetchState->warnDifferentContractType(
1214
- ~existingContract=registeringContract,
1215
- ~dc=dcWithStartBlock,
1216
- )
1217
- false
1218
- | Some(_) => // Since the DC is registered by an earlier item in the query
1219
- // FIXME: This unsafely relies on the asc order of the items
1220
- // which is 99% true, but there were cases when the source ordering was wrong
1221
- false
1222
- | None => true
1223
- }
1224
- if shouldUpdate {
1225
- earliestRegisteringEventBlockNumber :=
1226
- Pervasives.min(
1227
- earliestRegisteringEventBlockNumber.contents,
1228
- dcWithStartBlock.effectiveStartBlock,
1229
- )
1230
- registeringContractsByContract
1231
- ->Utils.Dict.getOrInsertEmptyDict(dc.contractName)
1232
- ->Dict.set(dc.address->Address.toString, dcWithStartBlock)
1233
- registeringAddresses->Dict.set(dc.address->Address.toString, dcWithStartBlock)
1234
- } else {
1235
- shouldRemove := true
1236
- }
1237
- }
1238
- | None =>
1239
- let dcAsIndexingAddress: indexingAddress = {
1240
- address: dc.address,
1241
- contractName: dc.contractName,
1242
- registrationBlock: dc.registrationBlock,
1243
- effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1244
- ~registrationBlock=dc.registrationBlock,
1245
- ~contractStartBlock=None,
1246
- ),
1247
- }
1248
- // Prevent duplicate logging/persistence when the same address is
1249
- // already tracked on fetchState, either from the db on startup or
1250
- // from an earlier registration in this batch.
1251
- switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1252
- | Some(existingContract) =>
1253
- if existingContract.contractName != dc.contractName {
1254
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
1255
- }
1256
- shouldRemove := true
1257
- | None =>
1258
- switch registeringAddresses->Utils.Dict.dangerouslyGetNonOption(
1259
- dc.address->Address.toString,
1260
- ) {
1261
- | Some(existingContract) =>
1262
- if existingContract.contractName != dc.contractName {
1263
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
1264
- }
1265
- // Otherwise already queued for persistence by an earlier item in this batch.
1266
- shouldRemove := true
1267
- | None =>
1268
- let logger = Logging.createChild(
1269
- ~params={
1270
- "chainId": fetchState.chainId,
1271
- "contractAddress": dc.address->Address.toString,
1272
- "contractName": dc.contractName,
1273
- },
1274
- )
1275
- // Persist the address to the db so a future config change that
1276
- // adds events for this contract can pick it up on restart, but
1277
- // skip partition registration since there's nothing to fetch.
1278
- logger->Logging.childWarn(`Persisting contract registration without fetching: Contract doesn't have any events to fetch. It'll be picked up on restart if you add events for the contract.`)
1279
- noEventsAddresses->Dict.set(dc.address->Address.toString, dcAsIndexingAddress)
1280
- registeringAddresses->Dict.set(dc.address->Address.toString, dcAsIndexingAddress)
1281
- }
1282
- }
1283
- }
1369
+ for dcIdx in 0 to dcs->Array.length - 1 {
1370
+ let dc = dcs->Array.getUnsafe(dcIdx)
1371
+ registrations
1372
+ ->Array.push({
1373
+ address: dc.address,
1374
+ contractName: dc.contractName,
1375
+ registrationBlock: dc.registrationBlock,
1376
+ })
1377
+ ->ignore
1378
+ sourceDcs->Array.push(dcs)->ignore
1379
+ sourceIndexes->Array.push(dcIdx)->ignore
1380
+ }
1381
+ }
1382
+ }
1284
1383
 
1285
- if shouldRemove.contents {
1286
- // Remove the DC from item to prevent it from saving to the db
1287
- let _ = dcs->Array.splice(~start=idx.contents, ~remove=1, ~insert=[])
1288
- // Don't increment idx - next element shifted into current position
1289
- } else {
1290
- idx := idx.contents + 1
1291
- }
1384
+ // Ids are handed out in registration order, so a cursor taken now selects
1385
+ // exactly what this batch adds.
1386
+ let idCursor = addressStore->AddressStore.nextId
1387
+ // The store resolves each address against both what it already holds and the
1388
+ // batch's own earlier entries, so two contracts claiming one address inside a
1389
+ // single batch conflict the same way as across batches.
1390
+ let verdicts = addressStore->AddressStore.registerBatch(registrations)
1391
+
1392
+ let warn = (~contractAddress, ~params, message) =>
1393
+ Logging.createChild(
1394
+ ~params={
1395
+ "chainId": fetchState.chainId,
1396
+ "contractAddress": contractAddress,
1397
+ "details": params,
1398
+ },
1399
+ )->Logging.childWarn(message)
1400
+
1401
+ let registeringContractNames = []
1402
+ let hasNoEventsUpdatesRef = ref(false)
1403
+ for idx in 0 to verdicts->Array.length - 1 {
1404
+ let registration = registrations->Array.getUnsafe(idx)
1405
+ let contractAddress = registration.address->Address.toString
1406
+ let keep = switch verdicts->Array.getUnsafe(idx) {
1407
+ | Added(_) =>
1408
+ if !(registeringContractNames->Array.includes(registration.contractName)) {
1409
+ registeringContractNames->Array.push(registration.contractName)->ignore
1410
+ }
1411
+ true
1412
+ | NoEvents(_) =>
1413
+ hasNoEventsUpdatesRef := true
1414
+ // Persist the address to the db so a future config change that adds
1415
+ // events for this contract can pick it up on restart, but skip partition
1416
+ // registration since there's nothing to fetch.
1417
+ warn(
1418
+ ~contractAddress,
1419
+ ~params={"contractName": registration.contractName},
1420
+ `Persisting contract registration without fetching: Contract doesn't have any events to fetch. It'll be picked up on restart if you add events for the contract.`,
1421
+ )
1422
+ true
1423
+ | Conflict({existingContractName}) =>
1424
+ warn(
1425
+ ~contractAddress,
1426
+ ~params={
1427
+ "existingContractType": existingContractName,
1428
+ "newContractType": registration.contractName,
1429
+ },
1430
+ `Skipping contract registration: Contract address is already registered for one contract and cannot be registered for another contract.`,
1431
+ )
1432
+ false
1433
+ | Duplicate({effectiveStartBlock, existingEffectiveStartBlock}) =>
1434
+ // FIXME: Instead of filtering out duplicates, we should check the block
1435
+ // number first. If a new registration has an earlier block number we
1436
+ // should register it for the missing block range.
1437
+ if existingEffectiveStartBlock > effectiveStartBlock {
1438
+ warn(
1439
+ ~contractAddress,
1440
+ ~params={
1441
+ "existingBlockNumber": existingEffectiveStartBlock,
1442
+ "newBlockNumber": effectiveStartBlock,
1443
+ },
1444
+ `Skipping contract registration: Contract address is already registered at a later block number. Currently registration of the same contract address is not supported by Envio. Reach out to us if it's a problem for you.`,
1445
+ )
1292
1446
  }
1447
+ false
1448
+ | Invalid =>
1449
+ warn(
1450
+ ~contractAddress,
1451
+ ~params={"contractName": registration.contractName},
1452
+ `Skipping contract registration: Not a valid address for this chain's ecosystem.`,
1453
+ )
1454
+ false
1455
+ }
1456
+ if !keep {
1457
+ // Mark for removal; the splice happens below, back to front, so earlier
1458
+ // indexes stay valid.
1459
+ sourceIndexes->Array.setUnsafe(idx, -1 - sourceIndexes->Array.getUnsafe(idx))
1460
+ }
1461
+ }
1462
+ for idx in verdicts->Array.length - 1 downto 0 {
1463
+ let marked = sourceIndexes->Array.getUnsafe(idx)
1464
+ if marked < 0 {
1465
+ let _ =
1466
+ sourceDcs->Array.getUnsafe(idx)->Array.splice(~start=-1 - marked, ~remove=1, ~insert=[])
1293
1467
  }
1294
1468
  }
1295
1469
 
1296
- let dcContractNamesToStore = registeringContractsByContract->Dict.keysToArray
1297
- let hasNoEventsUpdates = !(noEventsAddresses->Utils.Dict.isEmpty)
1298
- switch (dcContractNamesToStore, hasNoEventsUpdates) {
1299
- // Dont update anything when everything was filter out
1470
+ switch (registeringContractNames, hasNoEventsUpdatesRef.contents) {
1471
+ // Dont update anything when everything was filtered out
1300
1472
  | ([], false) => fetchState
1301
- | ([], true) =>
1302
- // Only dcs for contracts without events. Track them on
1303
- // indexingAddresses so subsequent registrations see them, but don't touch
1304
- // partitions since there's nothing to fetch for them.
1305
- indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1306
- fetchState
1473
+ // Only dcs for contracts without events. The store already tracks them so
1474
+ // subsequent registrations see them, but there are no partitions to touch.
1475
+ | ([], true) => fetchState
1307
1476
  | (_, _) => {
1308
1477
  let newPartitions = []
1309
1478
  let dynamicContractsRef = ref(fetchState.optimizedPartitions.dynamicContracts)
1310
1479
  let mutExistingPartitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
1311
1480
 
1312
- for idx in 0 to dcContractNamesToStore->Array.length - 1 {
1313
- let contractName = dcContractNamesToStore->Array.getUnsafe(idx)
1481
+ for idx in 0 to registeringContractNames->Array.length - 1 {
1482
+ let contractName = registeringContractNames->Array.getUnsafe(idx)
1314
1483
 
1315
1484
  // When a new contract name is added as a dynamic contract for the first time (not in dynamicContracts set):
1316
1485
  // Walks through existing partitions that have addresses for this contract name
@@ -1323,12 +1492,12 @@ let registerDynamicContracts = (
1323
1492
 
1324
1493
  for idx in 0 to mutExistingPartitions->Array.length - 1 {
1325
1494
  let p = mutExistingPartitions->Array.getUnsafe(idx)
1326
- switch p.addressesByContractName->Utils.Dict.dangerouslyGetNonOption(contractName) {
1327
- | None => () // Skip partitions which don't have our contract
1328
- | Some(addresses) =>
1495
+ switch p.addresses->AddressSet.countFor(contractName) {
1496
+ | 0 => () // Skip partitions which don't have our contract
1497
+ | _ =>
1329
1498
  // Also filter out partitions which are 100% not mergable
1330
1499
  if p.selection.dependsOnAddresses && p.mergeBlock === None {
1331
- let allPartitionContractNames = p.addressesByContractName->Dict.keysToArray
1500
+ let allPartitionContractNames = p.addresses->AddressSet.contractNames
1332
1501
  switch allPartitionContractNames {
1333
1502
  | [_] =>
1334
1503
  mutExistingPartitions->Array.setUnsafe(
@@ -1351,26 +1520,20 @@ let registerDynamicContracts = (
1351
1520
  (fetchState.optimizedPartitions.nextPartitionIndex +
1352
1521
  newPartitions->Array.length)->Int.toString
1353
1522
 
1354
- let restAddressesByContractName =
1355
- p.addressesByContractName->Utils.Dict.shallowCopy
1356
- restAddressesByContractName->Utils.Dict.deleteInPlace(contractName)
1357
-
1523
+ let restNames =
1524
+ allPartitionContractNames->Array.filter(name => name !== contractName)
1358
1525
  mutExistingPartitions->Array.setUnsafe(
1359
1526
  idx,
1360
- {
1361
- ...p,
1362
- addressesByContractName: restAddressesByContractName,
1363
- },
1527
+ p->withAddresses(p.addresses->AddressSet.filterByContracts(restNames)),
1364
1528
  )
1365
1529
 
1366
- let addressesByContractName = Dict.make()
1367
- addressesByContractName->Dict.set(contractName, addresses)
1530
+ let splitAddresses = p.addresses->AddressSet.filterByContracts([contractName])
1368
1531
  newPartitions->Array.push({
1369
1532
  id: newPartitionId,
1370
1533
  latestFetchedBlock: p.latestFetchedBlock,
1371
1534
  selection: fetchState.normalSelection,
1372
1535
  dynamicContract: Some(contractName),
1373
- addressesByContractName,
1536
+ addresses: splitAddresses,
1374
1537
  mergeBlock: None,
1375
1538
  mutPendingQueries: p.mutPendingQueries,
1376
1539
  sourceRangeCapacity: p.sourceRangeCapacity,
@@ -1385,22 +1548,59 @@ let registerDynamicContracts = (
1385
1548
  }
1386
1549
  }
1387
1550
  }
1551
+ }
1388
1552
 
1389
- let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1390
- indexingAddresses->IndexingAddresses.register(registeringContracts)
1553
+ // Switch any dynamic contract that has just crossed the server-side
1554
+ // address threshold to client-side filtering. Sticky: the set only grows, and
1555
+ // collapse in createPartitions folds the contract's
1556
+ // partitions into the single address-free partition.
1557
+ // Clone the sticky set before mutating so this update owns its copy and
1558
+ // older fetchState snapshots keep theirs (Utils.Set.add mutates in place).
1559
+ let clientFilteredContracts = switch fetchState.clientFilterAddressThreshold {
1560
+ | Some(threshold) =>
1561
+ let clientFilteredContracts =
1562
+ fetchState.optimizedPartitions.clientFilteredContracts
1563
+ ->Utils.Set.toArray
1564
+ ->Utils.Set.fromArray
1565
+ dynamicContractsRef.contents
1566
+ ->Utils.Set.toArray
1567
+ ->Array.forEach(contractName => {
1568
+ let addressCount = addressStore->AddressStore.contractCount(contractName)
1569
+ if !(clientFilteredContracts->Utils.Set.has(contractName)) && addressCount > threshold {
1570
+ clientFilteredContracts->addClientFilteredContract(
1571
+ ~contractName,
1572
+ ~chainId=fetchState.chainId,
1573
+ ~addressCount,
1574
+ ~threshold,
1575
+ )
1576
+ }
1577
+ })
1578
+ clientFilteredContracts
1579
+ | None => fetchState.optimizedPartitions.clientFilteredContracts
1391
1580
  }
1392
- // Include no-events dcs so later batches detect conflicts against them.
1393
- indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1394
1581
 
1395
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
1396
- ~registeringContractsByContract,
1582
+ // Only this batch's additions need partitions; everything already
1583
+ // registered has one.
1584
+ let registeringSetsByContract = Dict.make()
1585
+ registeringContractNames->Array.forEach(contractName => {
1586
+ registeringSetsByContract->Dict.set(
1587
+ contractName,
1588
+ addressStore->AddressStore.makeSet(~contractName, ~options={minId: idCursor}),
1589
+ )
1590
+ })
1591
+
1592
+ let optimizedPartitions = createPartitions(
1593
+ ~registeringSetsByContract,
1594
+ ~addressStore,
1397
1595
  ~dynamicContracts=dynamicContractsRef.contents,
1596
+ ~clientFilteredContracts,
1398
1597
  ~normalSelection=fetchState.normalSelection,
1399
1598
  ~maxAddrInPartition=fetchState.optimizedPartitions.maxAddrInPartition,
1400
1599
  ~nextPartitionIndex=fetchState.optimizedPartitions.nextPartitionIndex +
1401
1600
  newPartitions->Array.length,
1402
1601
  ~existingPartitions=mutExistingPartitions->Array.concat(newPartitions),
1403
1602
  ~progressBlockNumber=0,
1603
+ ~knownHeight=Pervasives.max(claimCeiling, fetchState.knownHeight),
1404
1604
  )
1405
1605
 
1406
1606
  fetchState->updateInternal(~optimizedPartitions)
@@ -1408,26 +1608,6 @@ let registerDynamicContracts = (
1408
1608
  }
1409
1609
  }
1410
1610
 
1411
- // Drop events an address-param filter rejects. A merged partition may
1412
- // over-fetch a wildcard event whose indexed address param references an
1413
- // address registered after the log's block; `clientAddressFilter` is the
1414
- // param-level analogue of the srcAddress effectiveStartBlock check.
1415
- let filterByClientAddress = (
1416
- items: array<Internal.item>,
1417
- ~indexingAddresses: IndexingAddresses.t,
1418
- ): array<Internal.item> =>
1419
- items->Array.filter(item =>
1420
- switch item {
1421
- | Internal.Event({payload, blockNumber}) as item =>
1422
- switch (item->Internal.castUnsafeEventItem).onEventRegistration.clientAddressFilter {
1423
- | Some(filter) =>
1424
- filter(payload, blockNumber, indexingAddresses->IndexingAddresses.rawForFilter)
1425
- | None => true
1426
- }
1427
- | _ => true
1428
- }
1429
- )
1430
-
1431
1611
  /*
1432
1612
  Updates fetchState with a response for a given query.
1433
1613
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1505,26 +1685,19 @@ let maxInFlightChunksPerPartition = 12
1505
1685
  // Most parallel in-flight queries a single chain may have at once, across all
1506
1686
  // its partitions. Bounds source load on chains with many partitions, where the
1507
1687
  // per-partition cap alone would admit thousands of concurrent queries.
1508
- let maxChainConcurrency = 100
1688
+ let maxChainConcurrency = Env.maxChainConcurrency
1509
1689
 
1510
1690
  // Chunk spans grow by this factor over the smallest recently observed source
1511
1691
  // range, so the pipeline keeps probing for more capacity instead of locking in
1512
1692
  // the first measurement.
1513
1693
  let chunkRangeGrowthFactor = 1.8
1514
1694
 
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.
1695
+ // Push one density-priced query and return its itemsEst, the density estimate
1696
+ // that sizes the query and the chain's budget reservation. These queries are
1697
+ // chunks and gap-fills: their toBlock is a tight bound sized to the source's
1698
+ // range capacity, so they send no server cap (itemsTarget None) — a cap would
1699
+ // only self-truncate the bounded range, worse under client-side address
1700
+ // filtering. A rare unbounded call caps at the estimate as its only bound.
1528
1701
  let pushDensityPricedQuery = (
1529
1702
  queries: array<query>,
1530
1703
  ~partitionId,
@@ -1532,19 +1705,11 @@ let pushDensityPricedQuery = (
1532
1705
  ~toBlock,
1533
1706
  ~isChunk,
1534
1707
  ~density,
1535
- ~chunkItemsMultiplier,
1536
1708
  ~chainTargetBlock,
1537
- ~itemsTargetFloor,
1538
1709
  ~selection,
1539
- ~addressesByContractName,
1710
+ ~addresses,
1540
1711
  ) => {
1541
1712
  let itemsEst = densityItemsTarget(~density, ~fromBlock, ~toBlock, ~chainTargetBlock)
1542
- let itemsTarget = densityItemsTarget(
1543
- ~density=density *. chunkItemsMultiplier,
1544
- ~fromBlock,
1545
- ~toBlock,
1546
- ~chainTargetBlock,
1547
- )
1548
1713
  queries
1549
1714
  ->Array.push({
1550
1715
  partitionId,
@@ -1553,11 +1718,11 @@ let pushDensityPricedQuery = (
1553
1718
  selection,
1554
1719
  isChunk,
1555
1720
  itemsTarget: switch toBlock {
1556
- | Some(_) => Pervasives.max(itemsTarget, itemsTargetFloor)
1557
- | None => itemsTarget
1721
+ | Some(_) => None
1722
+ | None => Some(itemsEst)
1558
1723
  },
1559
1724
  itemsEst,
1560
- addressesByContractName,
1725
+ addresses,
1561
1726
  })
1562
1727
  ->ignore
1563
1728
  itemsEst
@@ -1585,10 +1750,8 @@ let pushGapFillQueries = (
1585
1750
  ~maxChunks: int,
1586
1751
  ~partition: partition,
1587
1752
  ~partitionBudget: float,
1588
- ~chunkItemsMultiplier: float,
1589
- ~itemsTargetFloor: int,
1590
1753
  ~selection: selection,
1591
- ~addressesByContractName: dict<array<Address.t>>,
1754
+ ~addresses: AddressSet.t,
1592
1755
  ) => {
1593
1756
  // Gaps past the chain's target block wait: they regenerate from the
1594
1757
  // pending-walk each tick and fill once the target reaches them. The lagged
@@ -1610,11 +1773,9 @@ let pushGapFillQueries = (
1610
1773
  ~toBlock=rangeEndBlock,
1611
1774
  ~isChunk,
1612
1775
  ~density,
1613
- ~chunkItemsMultiplier,
1614
1776
  ~chainTargetBlock,
1615
- ~itemsTargetFloor,
1616
1777
  ~selection,
1617
- ~addressesByContractName,
1778
+ ~addresses,
1618
1779
  )
1619
1780
  ->ignore
1620
1781
  switch (partition->getTrustedDensity, maybeChunkRange) {
@@ -1634,11 +1795,9 @@ let pushGapFillQueries = (
1634
1795
  ~toBlock=Some(chunkToBlock),
1635
1796
  ~isChunk=true,
1636
1797
  ~density,
1637
- ~chunkItemsMultiplier,
1638
1798
  ~chainTargetBlock,
1639
- ~itemsTargetFloor,
1640
1799
  ~selection,
1641
- ~addressesByContractName,
1800
+ ~addresses,
1642
1801
  )
1643
1802
  ->ignore
1644
1803
  chunkFromBlock := chunkToBlock + 1
@@ -1684,8 +1843,6 @@ let walkPartitionPending = (
1684
1843
  ~candidates: array<query>,
1685
1844
  ~headBlockNumber: int,
1686
1845
  ~chainTargetBlock: int,
1687
- ~chunkItemsMultiplier: float,
1688
- ~itemsTargetFloor: int,
1689
1846
  ~partitionBudget: float,
1690
1847
  ~queryEndBlock: option<int>,
1691
1848
  ): option<partitionFillState> => {
@@ -1712,10 +1869,8 @@ let walkPartitionPending = (
1712
1869
  ~maxChunks=maxInFlightChunksPerPartition - inFlightCount - chunksUsedThisCall.contents,
1713
1870
  ~partition=p,
1714
1871
  ~partitionBudget,
1715
- ~chunkItemsMultiplier,
1716
- ~itemsTargetFloor,
1717
1872
  ~selection=p.selection,
1718
- ~addressesByContractName=p.addressesByContractName,
1873
+ ~addresses=p.addresses,
1719
1874
  )
1720
1875
  chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
1721
1876
  }
@@ -1757,14 +1912,12 @@ let pushForwardCandidates = (
1757
1912
  // bound, see getNextQuery.
1758
1913
  ~inRangeStates: array<partitionFillState>,
1759
1914
  // 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.
1915
+ // this so each probe's itemsEst stays the honest per-partition share for
1916
+ // budget control — sizing by the (fewer) admittable queries would let every
1917
+ // accepted probe over-fetch its share.
1763
1918
  ~inRangeCount: int,
1764
1919
  ~chainTargetBlock: int,
1765
1920
  ~freshBudget: float,
1766
- ~chunkItemsMultiplier: float,
1767
- ~itemsTargetFloor: int,
1768
1921
  ) => {
1769
1922
  // Even share of the fresh budget across the partitions actually fetching
1770
1923
  // this tick (not every partition — so budget isn't stranded on ones below
@@ -1818,11 +1971,9 @@ let pushForwardCandidates = (
1818
1971
  ~toBlock=Some(chunkToBlock),
1819
1972
  ~isChunk=true,
1820
1973
  ~density,
1821
- ~chunkItemsMultiplier,
1822
1974
  ~chainTargetBlock,
1823
- ~itemsTargetFloor,
1824
1975
  ~selection=p.selection,
1825
- ~addressesByContractName=p.addressesByContractName,
1976
+ ~addresses=p.addresses,
1826
1977
  )
1827
1978
  generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
1828
1979
  chunkFromBlock := chunkToBlock + 1
@@ -1834,7 +1985,7 @@ let pushForwardCandidates = (
1834
1985
  // across the partitions fetching this tick. With no range to the target
1835
1986
  // fall back to an even share of the fresh budget, so cold chains and
1836
1987
  // caught-up partitions still probe.
1837
- let itemsTarget = if rangeToTarget > 0 {
1988
+ let itemsEst = if rangeToTarget > 0 {
1838
1989
  Pervasives.max(
1839
1990
  1,
1840
1991
  Math.round(
@@ -1853,9 +2004,11 @@ let pushForwardCandidates = (
1853
2004
  toBlock: fs.queryEndBlock,
1854
2005
  isChunk: false,
1855
2006
  selection: p.selection,
1856
- itemsTarget,
1857
- itemsEst: itemsTarget,
1858
- addressesByContractName: p.addressesByContractName,
2007
+ // An open-ended probe's range isn't bounded to source capacity, so it
2008
+ // keeps a server cap at its estimate to protect the shared buffer.
2009
+ itemsTarget: Some(itemsEst),
2010
+ itemsEst,
2011
+ addresses: p.addresses,
1859
2012
  })
1860
2013
  ->ignore
1861
2014
  }
@@ -1958,10 +2111,6 @@ let getNextQuery = (
1958
2111
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1959
2112
  ~chainTargetBlock: int,
1960
2113
  ~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
2114
  ) => {
1966
2115
  let headBlockNumber = knownHeight - blockLag
1967
2116
  if headBlockNumber <= 0 {
@@ -2079,8 +2228,6 @@ let getNextQuery = (
2079
2228
  ~candidates,
2080
2229
  ~headBlockNumber,
2081
2230
  ~chainTargetBlock,
2082
- ~chunkItemsMultiplier,
2083
- ~itemsTargetFloor,
2084
2231
  ~partitionBudget,
2085
2232
  ~queryEndBlock=computeQueryEndBlock(p),
2086
2233
  ) {
@@ -2124,8 +2271,6 @@ let getNextQuery = (
2124
2271
  ~inRangeCount,
2125
2272
  ~chainTargetBlock,
2126
2273
  ~freshBudget,
2127
- ~chunkItemsMultiplier,
2128
- ~itemsTargetFloor,
2129
2274
  )
2130
2275
 
2131
2276
  acceptCandidates(
@@ -2187,7 +2332,7 @@ let make = (
2187
2332
  ~startBlock,
2188
2333
  ~endBlock,
2189
2334
  ~onEventRegistrations: array<Internal.onEventRegistration>,
2190
- ~contractConfigs: dict<IndexingAddresses.contractConfig>,
2335
+ ~addressStore: AddressStore.t,
2191
2336
  ~addresses: array<Internal.indexingAddress>,
2192
2337
  ~maxAddrInPartition,
2193
2338
  ~chainId,
@@ -2197,6 +2342,7 @@ let make = (
2197
2342
  ~onBlockRegistrations=[],
2198
2343
  ~blockLag=0,
2199
2344
  ~firstEventBlock=None,
2345
+ ~clientFilterAddressThreshold=None,
2200
2346
  ): t => {
2201
2347
  let latestFetchedBlock = {
2202
2348
  blockTimestamp: 0,
@@ -2226,7 +2372,7 @@ let make = (
2226
2372
  dependsOnAddresses: false,
2227
2373
  onEventRegistrations: notDependingOnAddresses,
2228
2374
  },
2229
- addressesByContractName: Dict.make(),
2375
+ addresses: addressStore->AddressStore.emptySet,
2230
2376
  mergeBlock: None,
2231
2377
  dynamicContract: None,
2232
2378
  mutPendingQueries: [],
@@ -2242,8 +2388,22 @@ let make = (
2242
2388
  onEventRegistrations: normalRegistrations,
2243
2389
  }
2244
2390
 
2245
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
2391
+ // Every address the chain indexes goes into the store — including ones whose
2392
+ // contract has no address-dependent events, so a later registration of the
2393
+ // same address still conflicts and the address is still persisted.
2394
+ addressStore
2395
+ ->AddressStore.registerBatch(
2396
+ addresses->Array.map((contract): AddressStore.registration => {
2397
+ address: contract.address,
2398
+ contractName: contract.contractName,
2399
+ registrationBlock: contract.registrationBlock,
2400
+ }),
2401
+ )
2402
+ ->ignore
2403
+
2246
2404
  let dynamicContracts = Utils.Set.make()
2405
+ let clientFilteredContracts = Utils.Set.make()
2406
+ let registeringSetsByContract = Dict.make()
2247
2407
 
2248
2408
  addresses->Array.forEach(contract => {
2249
2409
  let contractName = contract.contractName
@@ -2251,12 +2411,12 @@ let make = (
2251
2411
  // Only addresses whose contract has events that depend on addresses get
2252
2412
  // registered for active fetching via partitions.
2253
2413
  if contractNamesWithNormalEvents->Utils.Set.has(contractName) {
2254
- registeringContractsByContract
2255
- ->Utils.Dict.getOrInsertEmptyDict(contractName)
2256
- ->Dict.set(
2257
- contract.address->Address.toString,
2258
- IndexingAddresses.makeIndexingAddress(~contract, ~contractConfigs),
2259
- )
2414
+ if !(registeringSetsByContract->Dict.has(contractName)) {
2415
+ registeringSetsByContract->Dict.set(
2416
+ contractName,
2417
+ addressStore->AddressStore.makeSet(~contractName),
2418
+ )
2419
+ }
2260
2420
 
2261
2421
  // Detect dynamic contracts by registrationBlock
2262
2422
  if contract.registrationBlock !== -1 {
@@ -2265,14 +2425,36 @@ let make = (
2265
2425
  }
2266
2426
  })
2267
2427
 
2268
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
2269
- ~registeringContractsByContract,
2428
+ // Switch any contract already over the server-side address threshold to
2429
+ // client-side filtering at creation — a config contract with a large static
2430
+ // address list, or a dynamic contract restored from a large persisted set.
2431
+ switch clientFilterAddressThreshold {
2432
+ | Some(threshold) =>
2433
+ registeringSetsByContract->Utils.Dict.forEachWithKey((set, contractName) => {
2434
+ let addressCount = set->AddressSet.size
2435
+ if addressCount > threshold {
2436
+ clientFilteredContracts->addClientFilteredContract(
2437
+ ~contractName,
2438
+ ~chainId,
2439
+ ~addressCount,
2440
+ ~threshold,
2441
+ )
2442
+ }
2443
+ })
2444
+ | None => ()
2445
+ }
2446
+
2447
+ let optimizedPartitions = createPartitions(
2448
+ ~registeringSetsByContract,
2449
+ ~addressStore,
2270
2450
  ~dynamicContracts,
2451
+ ~clientFilteredContracts,
2271
2452
  ~normalSelection,
2272
2453
  ~maxAddrInPartition,
2273
2454
  ~nextPartitionIndex=partitions->Array.length,
2274
2455
  ~existingPartitions=partitions, // wildcard partition(s) if any
2275
2456
  ~progressBlockNumber,
2457
+ ~knownHeight,
2276
2458
  )
2277
2459
 
2278
2460
  if (
@@ -2313,7 +2495,6 @@ let make = (
2313
2495
 
2314
2496
  let fetchState = {
2315
2497
  optimizedPartitions,
2316
- contractConfigs,
2317
2498
  chainId,
2318
2499
  startBlock,
2319
2500
  endBlock,
@@ -2325,6 +2506,7 @@ let make = (
2325
2506
  knownHeight,
2326
2507
  buffer,
2327
2508
  firstEventBlock,
2509
+ clientFilterAddressThreshold,
2328
2510
  }
2329
2511
 
2330
2512
  fetchState
@@ -2365,16 +2547,29 @@ Always recreates optimized partitions to avoid duplicate addresses:
2365
2547
  - Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
2366
2548
  - Non-wildcard with lfb > target: delete, track addresses for recreation
2367
2549
  */
2368
- let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetBlockNumber) => {
2369
- // Step 1: Prune addresses registered after the target block. The pruned index is
2370
- // then the source of truth for partition cleanup below — an address survives iff
2371
- // it's still in the index.
2372
- indexingAddresses->IndexingAddresses.rollbackInPlace(~targetBlockNumber)
2550
+ let rollback = (fetchState: t, ~addressStore: AddressStore.t, ~targetBlockNumber) => {
2551
+ // Step 1: Prune addresses registered after the target block. The pruned store
2552
+ // is then the source of truth for partition cleanup below — an address
2553
+ // survives iff `filterByRegistrationBlock` keeps it.
2554
+ addressStore->AddressStore.rollback(targetBlockNumber)->ignore
2373
2555
 
2374
2556
  // Step 2: Categorize partitions
2375
2557
  let keptPartitions = []
2376
2558
  let nextKeptIdRef = ref(0)
2377
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
2559
+ let registeringSetsByContract: dict<AddressSet.t> = Dict.make()
2560
+ let collectForRecreation = (set: AddressSet.t) =>
2561
+ set
2562
+ ->AddressSet.contractNames
2563
+ ->Array.forEach(contractName => {
2564
+ let contractSet = set->AddressSet.filterByContracts([contractName])
2565
+ registeringSetsByContract->Dict.set(
2566
+ contractName,
2567
+ switch registeringSetsByContract->Utils.Dict.dangerouslyGetNonOption(contractName) {
2568
+ | Some(existing) => existing->AddressSet.merge(contractSet)
2569
+ | None => contractSet
2570
+ },
2571
+ )
2572
+ })
2378
2573
 
2379
2574
  let partitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
2380
2575
  for idx in 0 to partitions->Array.length - 1 {
@@ -2397,46 +2592,27 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2397
2592
 
2398
2593
  // Non-wildcard with lfb > target: delete, collect addresses for recreation
2399
2594
  | _ if p.latestFetchedBlock.blockNumber > targetBlockNumber =>
2400
- p.addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
2401
- addresses->Array.forEach(address => {
2402
- switch indexingAddresses->IndexingAddresses.get(address->Address.toString) {
2403
- | Some(indexingContract) =>
2404
- let registeringContracts =
2405
- registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
2406
- registeringContracts->Dict.set(address->Address.toString, indexingContract)
2407
- | None => ()
2408
- }
2409
- })
2410
- })
2595
+ collectForRecreation(p.addresses->AddressSet.filterByRegistrationBlock(targetBlockNumber))
2411
2596
 
2412
2597
  // Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
2413
- | {addressesByContractName} => {
2598
+ | _ => {
2414
2599
  // Cap mergeBlock at target
2415
2600
  let mergeBlock = switch p.mergeBlock {
2416
2601
  | Some(mergeBlock) if mergeBlock > targetBlockNumber => Some(targetBlockNumber)
2417
2602
  | other => other
2418
2603
  }
2419
2604
 
2420
- // Drop addresses pruned from the index
2421
- let rollbackedAddressesByContractName = Dict.make()
2422
- addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
2423
- let keptAddresses =
2424
- addresses->Array.filter(address =>
2425
- indexingAddresses->IndexingAddresses.get(address->Address.toString)->Option.isSome
2426
- )
2427
- if keptAddresses->Array.length > 0 {
2428
- rollbackedAddressesByContractName->Dict.set(contractName, keptAddresses)
2429
- }
2430
- })
2605
+ // Drop addresses pruned from the store
2606
+ let rollbackedAddresses =
2607
+ p.addresses->AddressSet.filterByRegistrationBlock(targetBlockNumber)
2431
2608
 
2432
- if !(rollbackedAddressesByContractName->Utils.Dict.isEmpty) {
2609
+ if !(rollbackedAddresses->AddressSet.isEmpty) {
2433
2610
  let id = nextKeptIdRef.contents->Int.toString
2434
2611
  nextKeptIdRef := nextKeptIdRef.contents + 1
2435
2612
  keptPartitions
2436
2613
  ->Array.push({
2437
- ...p,
2614
+ ...p->withAddresses(rollbackedAddresses),
2438
2615
  id,
2439
- addressesByContractName: rollbackedAddressesByContractName,
2440
2616
  mutPendingQueries: rollbackPendingQueries(p.mutPendingQueries, ~targetBlockNumber),
2441
2617
  mergeBlock,
2442
2618
  })
@@ -2447,14 +2623,17 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2447
2623
  }
2448
2624
 
2449
2625
  // Step 3: Recreate partitions from deleted partition addresses
2450
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
2451
- ~registeringContractsByContract,
2626
+ let optimizedPartitions = createPartitions(
2627
+ ~registeringSetsByContract,
2628
+ ~addressStore,
2452
2629
  ~dynamicContracts=fetchState.optimizedPartitions.dynamicContracts,
2630
+ ~clientFilteredContracts=fetchState.optimizedPartitions.clientFilteredContracts,
2453
2631
  ~normalSelection=fetchState.normalSelection,
2454
2632
  ~maxAddrInPartition=fetchState.optimizedPartitions.maxAddrInPartition,
2455
2633
  ~nextPartitionIndex=nextKeptIdRef.contents,
2456
2634
  ~existingPartitions=keptPartitions,
2457
2635
  ~progressBlockNumber=targetBlockNumber,
2636
+ ~knownHeight=fetchState.knownHeight,
2458
2637
  )
2459
2638
 
2460
2639
  // Step 4: Update state