envio 3.5.0-alpha.1 → 3.5.0-alpha.3

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 (99) hide show
  1. package/index.d.ts +10 -6
  2. package/package.json +6 -6
  3. package/src/BatchProcessing.res +27 -0
  4. package/src/BatchProcessing.res.mjs +12 -0
  5. package/src/ChainFetching.res +5 -5
  6. package/src/ChainFetching.res.mjs +3 -4
  7. package/src/ChainState.res +48 -25
  8. package/src/ChainState.res.mjs +23 -29
  9. package/src/ChainState.resi +2 -6
  10. package/src/Change.res +3 -3
  11. package/src/Config.res +2 -6
  12. package/src/Config.res.mjs +2 -12
  13. package/src/Core.res +3 -0
  14. package/src/CrossChainState.res +33 -10
  15. package/src/CrossChainState.res.mjs +21 -6
  16. package/src/CrossChainState.resi +3 -0
  17. package/src/EntityId.res +15 -0
  18. package/src/EntityId.res.mjs +9 -0
  19. package/src/EventConfigBuilder.res +1 -58
  20. package/src/EventConfigBuilder.res.mjs +1 -33
  21. package/src/FetchState.res +510 -460
  22. package/src/FetchState.res.mjs +413 -428
  23. package/src/FinalizeBackfill.res +34 -0
  24. package/src/FinalizeBackfill.res.mjs +26 -0
  25. package/src/HandlerRegister.res +45 -0
  26. package/src/HandlerRegister.res.mjs +43 -0
  27. package/src/InMemoryStore.res +11 -4
  28. package/src/InMemoryTable.res +26 -24
  29. package/src/InMemoryTable.res.mjs +19 -18
  30. package/src/IndexerState.res +71 -2
  31. package/src/IndexerState.res.mjs +67 -3
  32. package/src/IndexerState.resi +5 -0
  33. package/src/Internal.res +17 -8
  34. package/src/LoadLayer.res +9 -1
  35. package/src/LoadLayer.res.mjs +1 -0
  36. package/src/LogSelection.res +2 -1
  37. package/src/Metrics.res +37 -6
  38. package/src/Metrics.res.mjs +5 -1
  39. package/src/Persistence.res +12 -1
  40. package/src/PgStorage.res +411 -57
  41. package/src/PgStorage.res.mjs +262 -41
  42. package/src/SimulateItems.res +2 -2
  43. package/src/TestIndexer.res +9 -5
  44. package/src/TestIndexer.res.mjs +5 -2
  45. package/src/UserContext.res +3 -3
  46. package/src/Writing.res +12 -2
  47. package/src/Writing.res.mjs +13 -2
  48. package/src/bindings/ClickHouse.res +5 -3
  49. package/src/bindings/ClickHouse.res.mjs +2 -5
  50. package/src/bindings/Vitest.res +22 -1
  51. package/src/bindings/Vitest.res.mjs +15 -0
  52. package/src/db/EntityFilter.res +0 -4
  53. package/src/db/EntityFilter.res.mjs +1 -8
  54. package/src/db/EntityHistory.res +16 -7
  55. package/src/db/EntityHistory.res.mjs +7 -6
  56. package/src/db/IndexRegistry.res +219 -0
  57. package/src/db/IndexRegistry.res.mjs +195 -0
  58. package/src/db/InternalTable.res +7 -0
  59. package/src/db/InternalTable.res.mjs +7 -0
  60. package/src/db/Table.res +53 -22
  61. package/src/db/Table.res.mjs +56 -18
  62. package/src/sources/AddressSet.res +48 -0
  63. package/src/sources/AddressSet.res.mjs +11 -0
  64. package/src/sources/AddressStore.res +152 -0
  65. package/src/sources/AddressStore.res.mjs +97 -0
  66. package/src/sources/EvmChain.res +3 -0
  67. package/src/sources/EvmChain.res.mjs +4 -2
  68. package/src/sources/EvmHyperSyncSource.res +6 -3
  69. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  70. package/src/sources/EvmRpcClient.res +6 -3
  71. package/src/sources/EvmRpcClient.res.mjs +3 -3
  72. package/src/sources/FuelHyperSync.res +4 -3
  73. package/src/sources/FuelHyperSync.res.mjs +3 -3
  74. package/src/sources/FuelHyperSync.resi +2 -1
  75. package/src/sources/FuelHyperSyncClient.res +12 -6
  76. package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
  77. package/src/sources/FuelHyperSyncSource.res +7 -4
  78. package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
  79. package/src/sources/HyperSync.res +6 -4
  80. package/src/sources/HyperSync.res.mjs +2 -3
  81. package/src/sources/HyperSync.resi +1 -1
  82. package/src/sources/HyperSyncClient.res +18 -9
  83. package/src/sources/HyperSyncClient.res.mjs +4 -4
  84. package/src/sources/RpcSource.res +15 -10
  85. package/src/sources/RpcSource.res.mjs +3 -4
  86. package/src/sources/SimulateSource.res +26 -11
  87. package/src/sources/SimulateSource.res.mjs +13 -5
  88. package/src/sources/Source.res +4 -2
  89. package/src/sources/SourceManager.res +2 -3
  90. package/src/sources/SourceManager.res.mjs +2 -3
  91. package/src/sources/Svm.res +1 -2
  92. package/src/sources/Svm.res.mjs +1 -1
  93. package/src/sources/SvmHyperSyncClient.res +12 -3
  94. package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
  95. package/src/sources/SvmHyperSyncSource.res +10 -5
  96. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  97. package/src/IndexingAddresses.res +0 -151
  98. package/src/IndexingAddresses.res.mjs +0 -130
  99. package/src/IndexingAddresses.resi +0 -39
@@ -15,9 +15,9 @@ type selection = {
15
15
  dependsOnAddresses: bool,
16
16
  // Contract names this query fetches address-free even though their
17
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.
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
21
  clientFilteredContracts?: array<string>,
22
22
  }
23
23
 
@@ -49,10 +49,13 @@ type partition = {
49
49
  // which added all its events to the queue
50
50
  latestFetchedBlock: blockNumberAndTimestamp,
51
51
  selection: selection,
52
- 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,
53
56
  mergeBlock: option<int>,
54
57
  // When set, partition indexes a single dynamic contract type.
55
- // The addressesByContractName must contain only addresses for this contract.
58
+ // `addresses` must then contain only addresses for this contract.
56
59
  dynamicContract: option<string>,
57
60
  // Mutated in place and shared across fetchState versions (updateInternal
58
61
  // copies the record, not this array): startFetchingQueries inserts,
@@ -92,26 +95,10 @@ type query = {
92
95
  // budget is reserved/consumed in and that sizes every query.
93
96
  itemsEst: int,
94
97
  selection: selection,
95
- addressesByContractName: dict<array<Address.t>>,
98
+ addresses: AddressSet.t,
96
99
  }
97
100
 
98
- // Invert addressesByContractName into address→contractName for log-ownership
99
- // routing. 1:1 today (each address belongs to one contract), so no key
100
- // collisions. Memoized on the addressesByContractName object so a partition's
101
- // many responses share one derivation and a large factory never rebuilds the
102
- // whole index; sound because the dict is immutable after construction (every
103
- // mutation produces a new dict).
104
- let deriveContractNameByAddress: dict<array<Address.t>> => dict<
105
- string,
106
- > = Utils.WeakMap.memoize(addressesByContractName => {
107
- let result = Dict.make()
108
- addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
109
- for i in 0 to addresses->Array.length - 1 {
110
- result->Dict.set(addresses->Array.getUnsafe(i)->Address.toString, contractName)
111
- }
112
- })
113
- result
114
- })
101
+ let withAddresses = (p: partition, addresses: AddressSet.t) => {...p, addresses}
115
102
 
116
103
  // itemsEst for a query over [fromBlock, toBlock] at the given event density
117
104
  // (items/block). toBlock None is the open-ended tail, capped at
@@ -200,10 +187,7 @@ module OptimizedPartitions = {
200
187
  ~maxAddrInPartition: int,
201
188
  ~nextPartitionIndexRef: ref<int>,
202
189
  ) => {
203
- let combinedAddresses =
204
- p1.addressesByContractName
205
- ->Dict.getUnsafe(contractName)
206
- ->Array.concat(p2.addressesByContractName->Dict.getUnsafe(contractName))
190
+ let combinedAddresses = p1.addresses->AddressSet.merge(p2.addresses)
207
191
 
208
192
  let p1Below = p1.latestFetchedBlock.blockNumber < potentialMergeBlock
209
193
  let p2Below = p2.latestFetchedBlock.blockNumber < potentialMergeBlock
@@ -240,7 +224,7 @@ module OptimizedPartitions = {
240
224
  selection: p1.selection,
241
225
  latestFetchedBlock: {blockNumber: potentialMergeBlock, blockTimestamp: 0},
242
226
  mergeBlock: None,
243
- addressesByContractName: Dict.make(), // set below
227
+ addresses: p1.addresses, // replaced below
244
228
  mutPendingQueries: [],
245
229
  sourceRangeCapacity: minRange,
246
230
  prevSourceRangeCapacity: minRange,
@@ -250,29 +234,28 @@ module OptimizedPartitions = {
250
234
  }
251
235
 
252
236
  // Apply address split on the continuing partition
253
- if combinedAddresses->Array.length > maxAddrInPartition {
254
- let addressesFull = combinedAddresses->Array.slice(~start=0, ~end=maxAddrInPartition)
255
- let addressesRest = combinedAddresses->Array.slice(~start=maxAddrInPartition)
256
- let abcFull = Dict.make()
257
- abcFull->Dict.set(contractName, addressesFull)
258
- let abcRest = Dict.make()
259
- abcRest->Dict.set(contractName, addressesRest)
260
- 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
261
245
  let restId = nextPartitionIndexRef.contents->Int.toString
262
246
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
263
247
  completed
264
248
  ->Array.push({
265
- ...continuingBase,
249
+ ...continuingBase->withAddresses(
250
+ combinedAddresses->AddressSet.slice(~offset=maxAddrInPartition, ~limit=None),
251
+ ),
266
252
  id: restId,
267
- addressesByContractName: abcRest,
268
253
  mutPendingQueries: [],
269
254
  })
270
255
  ->ignore
271
256
  completed
272
257
  } else {
273
- let abc = Dict.make()
274
- abc->Dict.set(contractName, combinedAddresses)
275
- completed->Array.push({...continuingBase, addressesByContractName: abc})->ignore
258
+ completed->Array.push(continuingBase->withAddresses(combinedAddresses))->ignore
276
259
  completed
277
260
  }
278
261
  }
@@ -287,6 +270,47 @@ module OptimizedPartitions = {
287
270
  let ascSortFn = (a, b) =>
288
271
  Int.compare(a.latestFetchedBlock.blockNumber, b.latestFetchedBlock.blockNumber)
289
272
 
273
+ // Contracts a standing address-free partition already fetches client-side.
274
+ // Addresses registered for them after that partition passed get a normal
275
+ // address-bound partition which dies once it catches up (see make), instead
276
+ // of being folded into the address-free one.
277
+ let anchoredContracts = (partitions: array<partition>) => {
278
+ let set = Utils.Set.make()
279
+ partitions->Array.forEach(p =>
280
+ switch (p.mergeBlock, p.selection.clientFilteredContracts) {
281
+ | (None, Some(contractNames)) =>
282
+ contractNames->Array.forEach(name => set->Utils.Set.add(name)->ignore)
283
+ | _ => ()
284
+ }
285
+ )
286
+ set
287
+ }
288
+
289
+ // The furthest block an address-free partition's already-dispatched queries
290
+ // can deliver without the addresses registered from now on. A settled query
291
+ // claims only what it actually fetched — the rest of its range is re-queried
292
+ // later, by then against the updated address store — while an in-flight
293
+ // bounded query claims its whole toBlock. An in-flight unbounded query has no
294
+ // ceiling at all, so nothing is safe to stop a catch-up partition at yet.
295
+ let getAnchorSafeBlock = (p: partition) => {
296
+ let safeRef = ref(Some(p.latestFetchedBlock.blockNumber))
297
+ p.mutPendingQueries->Array.forEach(pq =>
298
+ switch (safeRef.contents, pq) {
299
+ | (None, _) => ()
300
+ | (Some(safe), {fetchedBlock: Some({blockNumber})}) =>
301
+ if blockNumber > safe {
302
+ safeRef := Some(blockNumber)
303
+ }
304
+ | (Some(safe), {toBlock: Some(toBlock)}) =>
305
+ if toBlock > safe {
306
+ safeRef := Some(toBlock)
307
+ }
308
+ | (Some(_), _) => safeRef := None
309
+ }
310
+ )
311
+ safeRef.contents
312
+ }
313
+
290
314
  /**
291
315
  * Optimizes partitions by finding opportunities to merge partitions that
292
316
  * are behind other partitions with same/superset of contract names.
@@ -305,22 +329,52 @@ module OptimizedPartitions = {
305
329
  let mergingPartitions = Dict.make()
306
330
  let nextPartitionIndexRef = ref(nextPartitionIndex)
307
331
 
332
+ // Where a catch-up partition for a client-filtered contract may stop, keyed
333
+ // by contract name. A contract absent from the dict while present in
334
+ // anchoredContractsSet has an unbounded query in flight on its address-free
335
+ // partition — its catch-up can't be bounded yet, and a later call will bound it.
336
+ let anchorSafeBlocks = Dict.make()
337
+ let anchoredContractsSet = Utils.Set.make()
338
+ if clientFilteredContracts->Utils.Set.size > 0 {
339
+ partitions->Array.forEach(p =>
340
+ switch (p.mergeBlock, p.selection.clientFilteredContracts) {
341
+ | (None, Some(contractNames)) =>
342
+ let safeBlock = p->getAnchorSafeBlock
343
+ contractNames->Array.forEach(name => {
344
+ anchoredContractsSet->Utils.Set.add(name)->ignore
345
+ switch safeBlock {
346
+ | Some(safeBlock) => anchorSafeBlocks->Dict.set(name, safeBlock)
347
+ | None => ()
348
+ }
349
+ })
350
+ | _ => ()
351
+ }
352
+ )
353
+ }
354
+
308
355
  for idx in 0 to partitions->Array.length - 1 {
309
356
  let p = partitions->Array.getUnsafe(idx)
310
357
  switch p {
358
+ // For now don't merge partitions with mergeBlock,
359
+ // assuming they are already merged,
360
+ // TODO: Although there might be cases with too far away mergeBlock,
361
+ // which is worth merging.
362
+ // A partition already at or past its merge block is done — normally
363
+ // handleQueryResponse removes it when the response lands, but a rollback
364
+ // can cap the merge block at the rolled-back frontier, leaving no
365
+ // response to ever remove it on.
366
+ | {mergeBlock: Some(mergeBlock)} =>
367
+ if p.latestFetchedBlock.blockNumber < mergeBlock {
368
+ newPartitions->Array.push(p)->ignore
369
+ }
311
370
  // Since it's not a dynamic contract partition,
312
371
  // there's no need for merge logic
313
372
  | {dynamicContract: None}
314
373
  | // Wildcard doesn't need merging
315
- {selection: {dependsOnAddresses: false}}
316
- | // For now don't merge partitions with mergeBlock,
317
- // assuming they are already merged,
318
- // TODO: Although there might be cases with too far away mergeBlock,
319
- // which is worth merging
320
- {mergeBlock: Some(_)} =>
374
+ {selection: {dependsOnAddresses: false}} =>
321
375
  newPartitions->Array.push(p)->ignore
322
376
  | {dynamicContract: Some(contractName)} =>
323
- let pAddressesCount = p.addressesByContractName->Dict.getUnsafe(contractName)->Array.length
377
+ let pAddressesCount = p.addresses->AddressSet.countFor(contractName)
324
378
  // Compute merge block: last pending query's toBlock, or lfb if idle
325
379
  let potentialMergeBlock = switch p.mutPendingQueries->Utils.Array.last {
326
380
  | Some({isChunk: true, toBlock: Some(toBlock)}) => Some(toBlock)
@@ -415,14 +469,40 @@ module OptimizedPartitions = {
415
469
  newPartitions->Array.push(currentPRef.contents)->ignore
416
470
  }
417
471
 
472
+ // A dynamic partition for a contract the address-free partition already
473
+ // fetches is a catch-up for addresses registered after that partition
474
+ // passed them. It merges by disappearing once it reaches the last block
475
+ // that partition's dispatched queries could have missed them on — its
476
+ // addresses are in the chain's store, so there is nothing to hand over.
477
+ let finalPartitions = if anchoredContractsSet->Utils.Set.size === 0 {
478
+ newPartitions
479
+ } else {
480
+ let anchored = []
481
+ newPartitions->Array.forEach(p =>
482
+ switch p {
483
+ | {dynamicContract: Some(contractName), mergeBlock: None}
484
+ if anchoredContractsSet->Utils.Set.has(contractName) =>
485
+ switch anchorSafeBlocks->Utils.Dict.dangerouslyGetNonOption(contractName) {
486
+ | None => anchored->Array.push(p)->ignore
487
+ | Some(anchorSafeBlock) =>
488
+ if p.latestFetchedBlock.blockNumber < anchorSafeBlock {
489
+ anchored->Array.push({...p, mergeBlock: Some(anchorSafeBlock)})->ignore
490
+ }
491
+ }
492
+ | _ => anchored->Array.push(p)->ignore
493
+ }
494
+ )
495
+ anchored
496
+ }
497
+
418
498
  // Sort partitions by latestFetchedBlock ascending
419
- let _ = newPartitions->Array.sort(ascSortFn)
499
+ let _ = finalPartitions->Array.sort(ascSortFn)
420
500
 
421
- let partitionsCount = newPartitions->Array.length
501
+ let partitionsCount = finalPartitions->Array.length
422
502
  let idsInAscOrder = Utils.Array.jsArrayCreate(partitionsCount)
423
503
  let entities = Dict.make()
424
504
  for idx in 0 to partitionsCount - 1 {
425
- let p = newPartitions->Array.getUnsafe(idx)
505
+ let p = finalPartitions->Array.getUnsafe(idx)
426
506
  idsInAscOrder->Array.setUnsafe(idx, p.id)
427
507
  entities->Dict.set(p.id, p)
428
508
  }
@@ -664,8 +744,6 @@ type t = {
664
744
  startBlock: int,
665
745
  endBlock: option<int>,
666
746
  normalSelection: selection,
667
- // By contract name
668
- contractConfigs: dict<IndexingAddresses.contractConfig>,
669
747
  // Not used for logic - only metadata
670
748
  chainId: int,
671
749
  // The block number of the latest block which was added to the queue
@@ -686,8 +764,7 @@ type t = {
686
764
  knownHeight: int,
687
765
  firstEventBlock: option<int>,
688
766
  // 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
767
+ // switched to client-side filtering. None disables the switch, leaving every
691
768
  // contract filtered server-side.
692
769
  clientFilterAddressThreshold: option<int>,
693
770
  }
@@ -944,7 +1021,6 @@ let updateInternal = (
944
1021
  let updatedFetchState = {
945
1022
  startBlock: fetchState.startBlock,
946
1023
  endBlock: fetchState.endBlock,
947
- contractConfigs: fetchState.contractConfigs,
948
1024
  normalSelection: fetchState.normalSelection,
949
1025
  chainId: fetchState.chainId,
950
1026
  onBlockRegistrations: fetchState.onBlockRegistrations,
@@ -965,40 +1041,6 @@ let updateInternal = (
965
1041
  updatedFetchState
966
1042
  }
967
1043
 
968
- let warnDifferentContractType = (
969
- fetchState,
970
- ~existingContract: indexingAddress,
971
- ~dc: indexingAddress,
972
- ) => {
973
- let logger = Logging.createChild(
974
- ~params={
975
- "chainId": fetchState.chainId,
976
- "contractAddress": dc.address->Address.toString,
977
- "existingContractType": existingContract.contractName,
978
- "newContractType": dc.contractName,
979
- },
980
- )
981
- logger->Logging.childWarn(`Skipping contract registration: Contract address is already registered for one contract and cannot be registered for another contract.`)
982
- }
983
-
984
- let addressesByContractNameCount = (addressesByContractName: dict<array<Address.t>>) => {
985
- let numAddresses = ref(0)
986
- addressesByContractName->Utils.Dict.forEach(addresses => {
987
- numAddresses := numAddresses.contents + addresses->Array.length
988
- })
989
- numAddresses.contents
990
- }
991
-
992
- let addressesByContractNameGetAll = (addressesByContractName: dict<array<Address.t>>) => {
993
- let all = []
994
- addressesByContractName->Utils.Dict.forEach(addresses => {
995
- for idx in 0 to addresses->Array.length - 1 {
996
- all->Array.push(addresses->Array.getUnsafe(idx))->ignore
997
- }
998
- })
999
- all
1000
- }
1001
-
1002
1044
  // Move a contract to client-side address filtering, recording why.
1003
1045
  let addClientFilteredContract = (
1004
1046
  clientFilteredContracts: Utils.Set.t<string>,
@@ -1006,20 +1048,44 @@ let addClientFilteredContract = (
1006
1048
  ~chainId,
1007
1049
  ~addressCount,
1008
1050
  ~threshold,
1051
+ // A resumed fetch state re-derives the switch from the persisted addresses,
1052
+ // but it already happened - and was logged - in the run that crossed the
1053
+ // threshold.
1054
+ ~shouldLog=true,
1009
1055
  ) => {
1010
1056
  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
- )
1057
+ if shouldLog {
1058
+ Logging.createChild(
1059
+ ~params={
1060
+ "chainId": chainId,
1061
+ "contractName": contractName,
1062
+ "addressCount": addressCount,
1063
+ "threshold": threshold,
1064
+ },
1065
+ )->Logging.childTrace(
1066
+ "Switching contract to client-side address filtering: registered address count crossed the server-side threshold.",
1067
+ )
1068
+ }
1021
1069
  }
1022
1070
 
1071
+ // The block a partition will have fetched to once everything on its pending
1072
+ // queue lands — not the block it has fetched to now. A backfill has to reach it
1073
+ // rather than the frontier: a query that was routed before this batch's
1074
+ // addresses reached the address store carries no events for them, yet it still
1075
+ // advances the frontier over its range on arrival, and nothing else would ever
1076
+ // refetch it. An in-flight open-ended query has no toBlock of its own; it can't
1077
+ // return past the chain's known height, so that bounds it.
1078
+ let claimedFetchedBlock = (p: partition, ~knownHeight) =>
1079
+ p.mutPendingQueries->Array.reduce(p.latestFetchedBlock.blockNumber, (max, q) =>
1080
+ Pervasives.max(
1081
+ max,
1082
+ switch q.fetchedBlock {
1083
+ | Some({blockNumber}) => blockNumber
1084
+ | None => q.toBlock->Option.getOr(knownHeight)
1085
+ },
1086
+ )
1087
+ )
1088
+
1023
1089
  // Fold every client-filtered contract's server-side partitions into client-side
1024
1090
  // fetching without tearing down established state:
1025
1091
  // - The standing address-free partition (no mergeBlock) keeps its id, frontier,
@@ -1027,14 +1093,18 @@ let addClientFilteredContract = (
1027
1093
  // must be added does its selection change — under a fresh id, so in-flight
1028
1094
  // responses built from the old selection are orphaned instead of advancing
1029
1095
  // 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.
1096
+ // - Partitions absorbed below the standing partition's claimed block
1097
+ // (single-contract dynamic partitions and config partitions all of whose
1098
+ // contracts are client-filtered, plus any prior backfill) become one bounded
1099
+ // backfill partition covering [their min frontier, that claimed block]:
1100
+ // getNextQuery caps its queries at mergeBlock and handleQueryResponse deletes
1101
+ // it on arrival. The overlap it re-delivers is deduped by mergeIntoBuffer, and
1102
+ // the re-fetch doubles as history for freshly registered addresses: events
1103
+ // dropped before the address was registered now pass the address gate.
1104
+ // - A dynamic partition for a contract the standing partition already covers is
1105
+ // left alone: it is a catch-up for addresses registered after that partition
1106
+ // passed them, and OptimizedPartitions.make bounds it against the standing
1107
+ // partition's own claims instead.
1038
1108
  // - A partition mixing client-filtered and server-side contracts stays,
1039
1109
  // stripped of the client-filtered contracts' addresses — the address-free
1040
1110
  // partition covers those logs, so fetching them server-side too would only
@@ -1050,6 +1120,12 @@ let collapseClientFilteredContracts = (
1050
1120
  ~clientFilteredContracts: Utils.Set.t<string>,
1051
1121
  ~normalSelection: selection,
1052
1122
  ~nextPartitionIndexRef: ref<int>,
1123
+ ~addressStore: AddressStore.t,
1124
+ // Frontiers of the addresses that were never given a server-side partition
1125
+ // because their contract is already client-filtered. They stand in for the
1126
+ // partitions that would otherwise have been created only to be absorbed here.
1127
+ ~clientFilteredFrontiers: array<blockNumberAndTimestamp>=[],
1128
+ ~knownHeight: int,
1053
1129
  ) => {
1054
1130
  if clientFilteredContracts->Utils.Set.size === 0 {
1055
1131
  partitions
@@ -1059,6 +1135,7 @@ let collapseClientFilteredContracts = (
1059
1135
  let backfills = []
1060
1136
  let absorbedPartitions = []
1061
1137
  let strippedFrontiers = []
1138
+ let anchoredContracts = partitions->OptimizedPartitions.anchoredContracts
1062
1139
 
1063
1140
  partitions->Array.forEach(p =>
1064
1141
  switch p {
@@ -1069,18 +1146,29 @@ let collapseClientFilteredContracts = (
1069
1146
  backfills->Array.push(p)->ignore
1070
1147
  | {selection: {dependsOnAddresses: false}} => absorbedPartitions->Array.push(p)->ignore
1071
1148
  | _ =>
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 {
1149
+ let contractNames = p.addresses->AddressSet.contractNames
1150
+ let serverSideNames =
1151
+ contractNames->Array.filter(c => !(clientFilteredContracts->Utils.Set.has(c)))
1152
+ if serverSideNames->Array.length === contractNames->Array.length {
1153
+ kept->Array.push(p)->ignore
1154
+ } else if (
1155
+ switch p.dynamicContract {
1156
+ | Some(contractName) => anchoredContracts->Utils.Set.has(contractName)
1157
+ | None => false
1158
+ }
1159
+ ) {
1160
+ // A catch-up partition for addresses registered after the address-free
1161
+ // partition already covered their contract. It is the correctness
1162
+ // barrier for those addresses until it catches up — absorbing it would
1163
+ // hand that job back to a guessed ceiling.
1076
1164
  kept->Array.push(p)->ignore
1077
- } else if clientFilteredNames->Array.length === contractNames->Array.length {
1165
+ } else if serverSideNames->Utils.Array.isEmpty {
1078
1166
  absorbedPartitions->Array.push(p)->ignore
1079
1167
  } else {
1080
- let stripped = p.addressesByContractName->Utils.Dict.shallowCopy
1081
- clientFilteredNames->Array.forEach(c => stripped->Utils.Dict.deleteInPlace(c))
1082
1168
  strippedFrontiers->Array.push(p.latestFetchedBlock)->ignore
1083
- kept->Array.push({...p, addressesByContractName: stripped})->ignore
1169
+ kept
1170
+ ->Array.push(p->withAddresses(p.addresses->AddressSet.filterByContracts(serverSideNames)))
1171
+ ->ignore
1084
1172
  }
1085
1173
  }
1086
1174
  )
@@ -1095,6 +1183,7 @@ let collapseClientFilteredContracts = (
1095
1183
  if (
1096
1184
  absorbedPartitions->Utils.Array.isEmpty &&
1097
1185
  strippedFrontiers->Utils.Array.isEmpty &&
1186
+ clientFilteredFrontiers->Utils.Array.isEmpty &&
1098
1187
  !selectionChanged
1099
1188
  ) {
1100
1189
  // Nothing to fold in and no newly-switched contract: leave the standing
@@ -1110,6 +1199,7 @@ let collapseClientFilteredContracts = (
1110
1199
  absorbedPartitions->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1111
1200
  backfills->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1112
1201
  strippedFrontiers->Array.forEach(considerFrontier)
1202
+ clientFilteredFrontiers->Array.forEach(considerFrontier)
1113
1203
 
1114
1204
  let regByIndex = Dict.make()
1115
1205
  let addRegs = (regs: array<Internal.onEventRegistration>) =>
@@ -1150,7 +1240,7 @@ let collapseClientFilteredContracts = (
1150
1240
  id,
1151
1241
  latestFetchedBlock: minFrontier,
1152
1242
  selection: newSelection,
1153
- addressesByContractName: Dict.make(),
1243
+ addresses: addressStore->AddressStore.emptySet,
1154
1244
  mergeBlock: None,
1155
1245
  dynamicContract: None,
1156
1246
  mutPendingQueries: [],
@@ -1171,8 +1261,12 @@ let collapseClientFilteredContracts = (
1171
1261
  standing
1172
1262
  }
1173
1263
  kept->Array.push(standingOut)->ignore
1264
+ // Read off standingOut, not standing: a selection change orphans the
1265
+ // in-flight queries (fresh id, empty queue), so there the frontier is
1266
+ // all the partition will ever claim.
1267
+ let catchUpToBlock = standingOut->claimedFetchedBlock(~knownHeight)
1174
1268
  switch minFrontierRef.contents {
1175
- | Some(minFrontier) if minFrontier.blockNumber < standing.latestFetchedBlock.blockNumber =>
1269
+ | Some(minFrontier) if minFrontier.blockNumber < catchUpToBlock =>
1176
1270
  let id = nextPartitionIndexRef.contents->Int.toString
1177
1271
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1178
1272
  kept
@@ -1180,8 +1274,8 @@ let collapseClientFilteredContracts = (
1180
1274
  id,
1181
1275
  latestFetchedBlock: minFrontier,
1182
1276
  selection: newSelection,
1183
- addressesByContractName: Dict.make(),
1184
- mergeBlock: Some(standing.latestFetchedBlock.blockNumber),
1277
+ addresses: addressStore->AddressStore.emptySet,
1278
+ mergeBlock: Some(catchUpToBlock),
1185
1279
  dynamicContract: None,
1186
1280
  mutPendingQueries: [],
1187
1281
  // Same query shape as the standing partition, so inherit its
@@ -1192,8 +1286,9 @@ let collapseClientFilteredContracts = (
1192
1286
  latestSourceRangeCapacityUpdateBlock: 0,
1193
1287
  })
1194
1288
  ->ignore
1195
- // An absorbed frontier at/above the standing frontier needs no
1196
- // backfill: the standing partition hasn't fetched past there yet.
1289
+ // An absorbed frontier at/above the claimed block needs no backfill:
1290
+ // nothing has fetched past there yet, so the standing partition still
1291
+ // covers it going forward.
1197
1292
  | _ => ()
1198
1293
  }
1199
1294
  kept
@@ -1209,8 +1304,11 @@ Phase 2: Merge non-dynamic partitions together to reduce unnecessary concurrency
1209
1304
  Returns OptimizedPartitions.t directly.
1210
1305
  (Dynamic partitions are merged by OptimizedPartitions.make automatically)
1211
1306
  */
1212
- let createPartitionsFromIndexingAddresses = (
1213
- ~registeringContractsByContract: dict<dict<indexingAddress>>,
1307
+ let createPartitions = (
1308
+ // One set per contract, holding the addresses that still need a partition:
1309
+ // a batch's fresh registrations, or the survivors of a rolled-back partition.
1310
+ ~registeringSetsByContract: dict<AddressSet.t>,
1311
+ ~addressStore: AddressStore.t,
1214
1312
  ~dynamicContracts: Utils.Set.t<string>,
1215
1313
  ~clientFilteredContracts: Utils.Set.t<string>,
1216
1314
  ~normalSelection: selection,
@@ -1218,6 +1316,7 @@ let createPartitionsFromIndexingAddresses = (
1218
1316
  ~nextPartitionIndex: int,
1219
1317
  ~existingPartitions: array<partition>,
1220
1318
  ~progressBlockNumber: int,
1319
+ ~knownHeight: int,
1221
1320
  ): // Floor for latestFetchedBlock (use progressBlockNumber from make, or 0 for registerDynamicContracts)
1222
1321
  OptimizedPartitions.t => {
1223
1322
  let nextPartitionIndexRef = ref(nextPartitionIndex)
@@ -1225,73 +1324,81 @@ OptimizedPartitions.t => {
1225
1324
  // ── Phase 1: Create per-contract-name partitions ──
1226
1325
  let dynamicPartitions = []
1227
1326
  let nonDynamicPartitions = []
1327
+ let clientFilteredFrontiers = []
1228
1328
 
1229
- let contractNames = registeringContractsByContract->Dict.keysToArray
1329
+ // Contracts an address-free partition already fetches. Their new addresses
1330
+ // need a partition of their own: the address-free partition passed the blocks
1331
+ // they were registered at without them in the store.
1332
+ let anchoredContracts = existingPartitions->OptimizedPartitions.anchoredContracts
1333
+
1334
+ let contractNames = registeringSetsByContract->Dict.keysToArray
1230
1335
  for cIdx in 0 to contractNames->Array.length - 1 {
1231
1336
  let contractName = contractNames->Array.getUnsafe(cIdx)
1232
- let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1233
- let addresses =
1234
- registeringContracts->Dict.keysToArray->(Utils.magic: array<string> => array<Address.t>)
1337
+ let contractSet = registeringSetsByContract->Dict.getUnsafe(contractName)
1235
1338
 
1236
- let isDynamic = dynamicContracts->Utils.Set.has(contractName)
1339
+ let isAnchored = anchoredContracts->Utils.Set.has(contractName)
1340
+ // A catch-up partition must go through the dynamic merge logic — that's
1341
+ // what bounds it against its anchor and removes it once it catches up.
1342
+ let isDynamic = dynamicContracts->Utils.Set.has(contractName) || isAnchored
1237
1343
  let partitions = isDynamic ? dynamicPartitions : nonDynamicPartitions
1238
1344
 
1239
- let byStartBlock = Dict.make()
1240
- for jdx in 0 to addresses->Array.length - 1 {
1241
- let address = addresses->Array.getUnsafe(jdx)
1242
- let indexingContract = registeringContracts->Dict.getUnsafe(address->Address.toString)
1243
- byStartBlock->Utils.Dict.push(indexingContract.effectiveStartBlock->Int.toString, address)
1244
- }
1245
-
1246
- // Will be in ASC order by JS spec
1247
- let ascKeys = byStartBlock->Dict.keysToArray
1248
- let initialKey = ascKeys->Utils.Array.firstUnsafe
1249
-
1250
- let startBlockRef = ref(initialKey->Int.fromString->Option.getUnsafe)
1251
- let addressesRef = ref(byStartBlock->Dict.getUnsafe(initialKey))
1252
-
1253
- for idx in 0 to ascKeys->Array.length - 1 {
1254
- let maybeNextStartBlockKey =
1255
- ascKeys->Array.getUnsafe(idx + 1)->(Utils.magic: string => option<string>)
1256
-
1257
- let shouldAllocateNewPartition = switch maybeNextStartBlockKey {
1258
- | None => true
1259
- | Some(nextStartBlockKey) => {
1260
- let nextStartBlock = nextStartBlockKey->Int.fromString->Option.getUnsafe
1261
- let shouldJoinCurrentStartBlock =
1262
- nextStartBlock - startBlockRef.contents < OptimizedPartitions.tooFarBlockRange
1263
-
1264
- // Addresses with different start blocks within range share a partition;
1265
- // events before each address's effectiveStartBlock are dropped on the
1266
- // client side (the event's clientAddressFilter for address-valued
1267
- // params).
1268
- if shouldJoinCurrentStartBlock {
1269
- addressesRef :=
1270
- addressesRef.contents->Array.concat(byStartBlock->Dict.getUnsafe(nextStartBlockKey))
1271
- false
1345
+ // A set is ordered by effectiveStartBlock, so its start-block groups are
1346
+ // ascending and each group's addresses are a contiguous slice.
1347
+ let groups = contractSet->AddressSet.startBlockGroups
1348
+
1349
+ if clientFilteredContracts->Utils.Set.has(contractName) && !isAnchored {
1350
+ // The contract is switching to client-side filtering in this very call, so
1351
+ // the collapse below folds everything it has into the address-free
1352
+ // partition anyway. Chunking these addresses by maxAddrInPartition would
1353
+ // only build partitions for it to absorb - hundreds of them for a contract
1354
+ // big enough to be client-filtered in the first place. All the collapse
1355
+ // needs is the earliest block the addresses aren't covered from, which is
1356
+ // the first group's since groups are ascending.
1357
+ switch groups->Array.get(0) {
1358
+ | Some({startBlock}) =>
1359
+ clientFilteredFrontiers->Array.push({
1360
+ blockNumber: Pervasives.max(startBlock - 1, progressBlockNumber),
1361
+ blockTimestamp: 0,
1362
+ })
1363
+ | None => ()
1364
+ }
1365
+ } else {
1366
+ let offsetRef = ref(0)
1367
+ let groupIdx = ref(0)
1368
+ while groupIdx.contents < groups->Array.length {
1369
+ let startBlock = (groups->Array.getUnsafe(groupIdx.contents)).startBlock
1370
+ // Addresses with different start blocks within range share a partition;
1371
+ // events before each address's effectiveStartBlock are dropped by the
1372
+ // source's address gate.
1373
+ let countRef = ref(0)
1374
+ let nextIdx = ref(groupIdx.contents)
1375
+ let joining = ref(true)
1376
+ while joining.contents && nextIdx.contents < groups->Array.length {
1377
+ let group = groups->Array.getUnsafe(nextIdx.contents)
1378
+ if group.startBlock - startBlock < OptimizedPartitions.tooFarBlockRange {
1379
+ countRef := countRef.contents + group.count
1380
+ nextIdx := nextIdx.contents + 1
1272
1381
  } else {
1273
- true
1382
+ joining := false
1274
1383
  }
1275
1384
  }
1276
- }
1277
1385
 
1278
- if shouldAllocateNewPartition {
1279
1386
  let latestFetchedBlock = {
1280
- blockNumber: Pervasives.max(startBlockRef.contents - 1, progressBlockNumber),
1387
+ blockNumber: Pervasives.max(startBlock - 1, progressBlockNumber),
1281
1388
  blockTimestamp: 0,
1282
1389
  }
1283
- while addressesRef.contents->Array.length > 0 {
1284
- let pAddresses = addressesRef.contents->Array.slice(~start=0, ~end=maxAddrInPartition)
1285
- addressesRef.contents = addressesRef.contents->Array.slice(~start=maxAddrInPartition)
1286
-
1287
- let addressesByContractName = Dict.make()
1288
- addressesByContractName->Dict.set(contractName, pAddresses)
1390
+ let remainingRef = ref(countRef.contents)
1391
+ let chunkOffsetRef = ref(offsetRef.contents)
1392
+ while remainingRef.contents > 0 {
1393
+ let take = Pervasives.min(remainingRef.contents, maxAddrInPartition)
1394
+ let pAddresses =
1395
+ contractSet->AddressSet.slice(~offset=chunkOffsetRef.contents, ~limit=Some(take))
1289
1396
  partitions->Array.push({
1290
1397
  id: nextPartitionIndexRef.contents->Int.toString,
1291
1398
  latestFetchedBlock,
1292
1399
  selection: normalSelection,
1293
1400
  dynamicContract: isDynamic ? Some(contractName) : None,
1294
- addressesByContractName,
1401
+ addresses: pAddresses,
1295
1402
  mergeBlock: None,
1296
1403
  mutPendingQueries: [],
1297
1404
  sourceRangeCapacity: 0,
@@ -1300,15 +1407,12 @@ OptimizedPartitions.t => {
1300
1407
  latestSourceRangeCapacityUpdateBlock: 0,
1301
1408
  })
1302
1409
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1410
+ chunkOffsetRef := chunkOffsetRef.contents + take
1411
+ remainingRef := remainingRef.contents - take
1303
1412
  }
1304
1413
 
1305
- switch maybeNextStartBlockKey {
1306
- | None => ()
1307
- | Some(nextStartBlockKey) => {
1308
- startBlockRef := nextStartBlockKey->Int.fromString->Option.getUnsafe
1309
- addressesRef := byStartBlock->Dict.getUnsafe(nextStartBlockKey)
1310
- }
1311
- }
1414
+ offsetRef := offsetRef.contents + countRef.contents
1415
+ groupIdx := nextIdx.contents
1312
1416
  }
1313
1417
  }
1314
1418
  }
@@ -1329,29 +1433,14 @@ OptimizedPartitions.t => {
1329
1433
  let currentPBlock = currentP.latestFetchedBlock.blockNumber
1330
1434
  let nextPBlock = nextP.latestFetchedBlock.blockNumber
1331
1435
 
1332
- // Compute total count WITHOUT mutating any arrays
1333
- let totalCount =
1334
- currentP.addressesByContractName->addressesByContractNameCount +
1335
- nextP.addressesByContractName->addressesByContractNameCount
1436
+ let totalCount = currentP.addresses->AddressSet.size + nextP.addresses->AddressSet.size
1336
1437
 
1337
1438
  if totalCount > maxAddrInPartition {
1338
1439
  // Exceeds address limit - don't merge, keep partitions separate
1339
1440
  mergedNonDynamic->Array.push(currentP)->ignore
1340
1441
  currentPRef := nextP
1341
1442
  } else {
1342
- // Build merged addresses using Array.concat (non-mutating)
1343
- let mergedAddresses = nextP.addressesByContractName->Utils.Dict.shallowCopy
1344
- let currentContractNames = currentP.addressesByContractName->Dict.keysToArray
1345
- for jdx in 0 to currentContractNames->Array.length - 1 {
1346
- let cn = currentContractNames->Array.getUnsafe(jdx)
1347
- let currentAddrs = currentP.addressesByContractName->Dict.getUnsafe(cn)
1348
- switch mergedAddresses->Utils.Dict.dangerouslyGetNonOption(cn) {
1349
- | Some(existingAddrs) =>
1350
- // Use concat (non-mutating) to avoid corrupting nextP's arrays
1351
- mergedAddresses->Dict.set(cn, existingAddrs->Array.concat(currentAddrs))
1352
- | None => mergedAddresses->Dict.set(cn, currentAddrs)
1353
- }
1354
- }
1443
+ let mergedAddresses = nextP.addresses->AddressSet.merge(currentP.addresses)
1355
1444
 
1356
1445
  let isTooFar = currentPBlock + OptimizedPartitions.tooFarBlockRange < nextPBlock
1357
1446
 
@@ -1363,16 +1452,10 @@ OptimizedPartitions.t => {
1363
1452
  mergeBlock: currentPBlock < nextPBlock ? Some(nextPBlock) : None,
1364
1453
  })
1365
1454
  ->ignore
1366
- currentPRef := {
1367
- ...nextP,
1368
- addressesByContractName: mergedAddresses,
1369
- }
1455
+ currentPRef := nextP->withAddresses(mergedAddresses)
1370
1456
  } else {
1371
1457
  // Close: push next's addresses into current
1372
- currentPRef := {
1373
- ...currentP,
1374
- addressesByContractName: mergedAddresses,
1375
- }
1458
+ currentPRef := currentP->withAddresses(mergedAddresses)
1376
1459
  }
1377
1460
  }
1378
1461
 
@@ -1393,6 +1476,9 @@ OptimizedPartitions.t => {
1393
1476
  ~clientFilteredContracts,
1394
1477
  ~normalSelection,
1395
1478
  ~nextPartitionIndexRef,
1479
+ ~addressStore,
1480
+ ~clientFilteredFrontiers,
1481
+ ~knownHeight,
1396
1482
  )
1397
1483
  OptimizedPartitions.make(
1398
1484
  ~partitions=allPartitions,
@@ -1405,7 +1491,14 @@ OptimizedPartitions.t => {
1405
1491
 
1406
1492
  let registerDynamicContracts = (
1407
1493
  fetchState: t,
1408
- ~indexingAddresses: IndexingAddresses.t,
1494
+ ~addressStore: AddressStore.t,
1495
+ // How far an in-flight open-ended query may end up claiming. These
1496
+ // registrations usually come out of a response that is applied right after
1497
+ // this call, and an unbounded query can reach past the height known when it
1498
+ // was dispatched — so the caller folds that response's own frontier and
1499
+ // height in here. Defaults to what the fetch state knows, which is all
1500
+ // there is to go on when no response is being applied.
1501
+ ~claimCeiling=fetchState.knownHeight,
1409
1502
  // These are raw items which might have dynamic contracts received from contractRegister call.
1410
1503
  // Might contain duplicates which we should filter out
1411
1504
  items: array<Internal.item>,
@@ -1417,167 +1510,130 @@ let registerDynamicContracts = (
1417
1510
  )
1418
1511
  }
1419
1512
 
1420
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
1421
- let earliestRegisteringEventBlockNumber = ref(%raw(`Infinity`))
1422
- // Addresses registered for contracts without matching events. These are not
1423
- // added to partitions, but they are tracked on indexingAddresses
1424
- // so that later conflicting registrations are detected, and are persisted
1425
- // to envio_addresses so they can be picked up on restart with updated config.
1426
- let noEventsAddresses: dict<indexingAddress> = Dict.make()
1427
- // Batch-level view of all addresses registered so far (across contracts,
1428
- // including no-events ones), so two contracts registering the same address
1429
- // within one batch conflict the same way as against indexingAddresses.
1430
- let registeringAddresses: dict<indexingAddress> = Dict.make()
1431
-
1513
+ // Flatten every item's dcs into one batch, remembering where each came from
1514
+ // so the rejected ones can be spliced back out of their item (which is what
1515
+ // keeps them from being persisted twice).
1516
+ let registrations: array<AddressStore.registration> = []
1517
+ let sourceDcs: array<Internal.dcs> = []
1518
+ let sourceIndexes: array<int> = []
1432
1519
  for itemIdx in 0 to items->Array.length - 1 {
1433
- let item = items->Array.getUnsafe(itemIdx)
1434
- switch item->Internal.getItemDcs {
1520
+ switch items->Array.getUnsafe(itemIdx)->Internal.getItemDcs {
1435
1521
  | None => ()
1436
1522
  | Some(dcs) =>
1437
- let idx = ref(0)
1438
- while idx.contents < dcs->Array.length {
1439
- let dc = dcs->Array.getUnsafe(idx.contents)
1440
-
1441
- let shouldRemove = ref(false)
1442
-
1443
- switch fetchState.contractConfigs->Utils.Dict.dangerouslyGetNonOption(dc.contractName) {
1444
- | Some({startBlock: contractStartBlock}) =>
1445
- let dcWithStartBlock: indexingAddress = {
1446
- address: dc.address,
1447
- contractName: dc.contractName,
1448
- registrationBlock: dc.registrationBlock,
1449
- effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1450
- ~registrationBlock=dc.registrationBlock,
1451
- ~contractStartBlock,
1452
- ),
1453
- }
1454
- // Prevent registering already indexing contracts
1455
- switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1456
- | Some(existingContract) =>
1457
- // FIXME: Instead of filtering out duplicates,
1458
- // we should check the block number first.
1459
- // If new registration with earlier block number
1460
- // we should register it for the missing block range
1461
- if existingContract.contractName != dc.contractName {
1462
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcWithStartBlock)
1463
- } else if existingContract.effectiveStartBlock > dcWithStartBlock.effectiveStartBlock {
1464
- let logger = Logging.createChild(
1465
- ~params={
1466
- "chainId": fetchState.chainId,
1467
- "contractAddress": dc.address->Address.toString,
1468
- "existingBlockNumber": existingContract.effectiveStartBlock,
1469
- "newBlockNumber": dcWithStartBlock.effectiveStartBlock,
1470
- },
1471
- )
1472
- 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.`)
1473
- }
1474
- shouldRemove := true
1475
- | None =>
1476
- let shouldUpdate = switch registeringAddresses->Utils.Dict.dangerouslyGetNonOption(
1477
- dc.address->Address.toString,
1478
- ) {
1479
- | Some(registeringContract) if registeringContract.contractName != dc.contractName =>
1480
- fetchState->warnDifferentContractType(
1481
- ~existingContract=registeringContract,
1482
- ~dc=dcWithStartBlock,
1483
- )
1484
- false
1485
- | Some(_) => // Since the DC is registered by an earlier item in the query
1486
- // FIXME: This unsafely relies on the asc order of the items
1487
- // which is 99% true, but there were cases when the source ordering was wrong
1488
- false
1489
- | None => true
1490
- }
1491
- if shouldUpdate {
1492
- earliestRegisteringEventBlockNumber :=
1493
- Pervasives.min(
1494
- earliestRegisteringEventBlockNumber.contents,
1495
- dcWithStartBlock.effectiveStartBlock,
1496
- )
1497
- registeringContractsByContract
1498
- ->Utils.Dict.getOrInsertEmptyDict(dc.contractName)
1499
- ->Dict.set(dc.address->Address.toString, dcWithStartBlock)
1500
- registeringAddresses->Dict.set(dc.address->Address.toString, dcWithStartBlock)
1501
- } else {
1502
- shouldRemove := true
1503
- }
1504
- }
1505
- | None =>
1506
- let dcAsIndexingAddress: indexingAddress = {
1507
- address: dc.address,
1508
- contractName: dc.contractName,
1509
- registrationBlock: dc.registrationBlock,
1510
- effectiveStartBlock: IndexingAddresses.deriveEffectiveStartBlock(
1511
- ~registrationBlock=dc.registrationBlock,
1512
- ~contractStartBlock=None,
1513
- ),
1514
- }
1515
- // Prevent duplicate logging/persistence when the same address is
1516
- // already tracked on fetchState, either from the db on startup or
1517
- // from an earlier registration in this batch.
1518
- switch indexingAddresses->IndexingAddresses.get(dc.address->Address.toString) {
1519
- | Some(existingContract) =>
1520
- if existingContract.contractName != dc.contractName {
1521
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
1522
- }
1523
- shouldRemove := true
1524
- | None =>
1525
- switch registeringAddresses->Utils.Dict.dangerouslyGetNonOption(
1526
- dc.address->Address.toString,
1527
- ) {
1528
- | Some(existingContract) =>
1529
- if existingContract.contractName != dc.contractName {
1530
- fetchState->warnDifferentContractType(~existingContract, ~dc=dcAsIndexingAddress)
1531
- }
1532
- // Otherwise already queued for persistence by an earlier item in this batch.
1533
- shouldRemove := true
1534
- | None =>
1535
- let logger = Logging.createChild(
1536
- ~params={
1537
- "chainId": fetchState.chainId,
1538
- "contractAddress": dc.address->Address.toString,
1539
- "contractName": dc.contractName,
1540
- },
1541
- )
1542
- // Persist the address to the db so a future config change that
1543
- // adds events for this contract can pick it up on restart, but
1544
- // skip partition registration since there's nothing to fetch.
1545
- 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.`)
1546
- noEventsAddresses->Dict.set(dc.address->Address.toString, dcAsIndexingAddress)
1547
- registeringAddresses->Dict.set(dc.address->Address.toString, dcAsIndexingAddress)
1548
- }
1549
- }
1550
- }
1523
+ for dcIdx in 0 to dcs->Array.length - 1 {
1524
+ let dc = dcs->Array.getUnsafe(dcIdx)
1525
+ registrations
1526
+ ->Array.push({
1527
+ address: dc.address,
1528
+ contractName: dc.contractName,
1529
+ registrationBlock: dc.registrationBlock,
1530
+ })
1531
+ ->ignore
1532
+ sourceDcs->Array.push(dcs)->ignore
1533
+ sourceIndexes->Array.push(dcIdx)->ignore
1534
+ }
1535
+ }
1536
+ }
1551
1537
 
1552
- if shouldRemove.contents {
1553
- // Remove the DC from item to prevent it from saving to the db
1554
- let _ = dcs->Array.splice(~start=idx.contents, ~remove=1, ~insert=[])
1555
- // Don't increment idx - next element shifted into current position
1556
- } else {
1557
- idx := idx.contents + 1
1558
- }
1538
+ // Ids are handed out in registration order, so a cursor taken now selects
1539
+ // exactly what this batch adds.
1540
+ let idCursor = addressStore->AddressStore.nextId
1541
+ // The store resolves each address against both what it already holds and the
1542
+ // batch's own earlier entries, so two contracts claiming one address inside a
1543
+ // single batch conflict the same way as across batches.
1544
+ let verdicts = addressStore->AddressStore.registerBatch(registrations)
1545
+
1546
+ let warn = (~contractAddress, ~params, message) =>
1547
+ Logging.createChild(
1548
+ ~params={
1549
+ "chainId": fetchState.chainId,
1550
+ "contractAddress": contractAddress,
1551
+ "details": params,
1552
+ },
1553
+ )->Logging.childWarn(message)
1554
+
1555
+ let registeringContractNames = []
1556
+ let hasNoEventsUpdatesRef = ref(false)
1557
+ for idx in 0 to verdicts->Array.length - 1 {
1558
+ let registration = registrations->Array.getUnsafe(idx)
1559
+ let contractAddress = registration.address->Address.toString
1560
+ let keep = switch verdicts->Array.getUnsafe(idx) {
1561
+ | Added(_) =>
1562
+ if !(registeringContractNames->Array.includes(registration.contractName)) {
1563
+ registeringContractNames->Array.push(registration.contractName)->ignore
1559
1564
  }
1565
+ true
1566
+ | NoEvents(_) =>
1567
+ hasNoEventsUpdatesRef := true
1568
+ // Persist the address to the db so a future config change that adds
1569
+ // events for this contract can pick it up on restart, but skip partition
1570
+ // registration since there's nothing to fetch.
1571
+ warn(
1572
+ ~contractAddress,
1573
+ ~params={"contractName": registration.contractName},
1574
+ `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.`,
1575
+ )
1576
+ true
1577
+ | Conflict({existingContractName}) =>
1578
+ warn(
1579
+ ~contractAddress,
1580
+ ~params={
1581
+ "existingContractType": existingContractName,
1582
+ "newContractType": registration.contractName,
1583
+ },
1584
+ `Skipping contract registration: Contract address is already registered for one contract and cannot be registered for another contract.`,
1585
+ )
1586
+ false
1587
+ | Duplicate({effectiveStartBlock, existingEffectiveStartBlock}) =>
1588
+ // FIXME: Instead of filtering out duplicates, we should check the block
1589
+ // number first. If a new registration has an earlier block number we
1590
+ // should register it for the missing block range.
1591
+ if existingEffectiveStartBlock > effectiveStartBlock {
1592
+ warn(
1593
+ ~contractAddress,
1594
+ ~params={
1595
+ "existingBlockNumber": existingEffectiveStartBlock,
1596
+ "newBlockNumber": effectiveStartBlock,
1597
+ },
1598
+ `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.`,
1599
+ )
1600
+ }
1601
+ false
1602
+ | Invalid =>
1603
+ warn(
1604
+ ~contractAddress,
1605
+ ~params={"contractName": registration.contractName},
1606
+ `Skipping contract registration: Not a valid address for this chain's ecosystem.`,
1607
+ )
1608
+ false
1609
+ }
1610
+ if !keep {
1611
+ // Mark for removal; the splice happens below, back to front, so earlier
1612
+ // indexes stay valid.
1613
+ sourceIndexes->Array.setUnsafe(idx, -1 - sourceIndexes->Array.getUnsafe(idx))
1614
+ }
1615
+ }
1616
+ for idx in verdicts->Array.length - 1 downto 0 {
1617
+ let marked = sourceIndexes->Array.getUnsafe(idx)
1618
+ if marked < 0 {
1619
+ let _ =
1620
+ sourceDcs->Array.getUnsafe(idx)->Array.splice(~start=-1 - marked, ~remove=1, ~insert=[])
1560
1621
  }
1561
1622
  }
1562
1623
 
1563
- let dcContractNamesToStore = registeringContractsByContract->Dict.keysToArray
1564
- let hasNoEventsUpdates = !(noEventsAddresses->Utils.Dict.isEmpty)
1565
- switch (dcContractNamesToStore, hasNoEventsUpdates) {
1566
- // Dont update anything when everything was filter out
1624
+ switch (registeringContractNames, hasNoEventsUpdatesRef.contents) {
1625
+ // Dont update anything when everything was filtered out
1567
1626
  | ([], false) => fetchState
1568
- | ([], true) =>
1569
- // Only dcs for contracts without events. Track them on
1570
- // indexingAddresses so subsequent registrations see them, but don't touch
1571
- // partitions since there's nothing to fetch for them.
1572
- indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1573
- fetchState
1627
+ // Only dcs for contracts without events. The store already tracks them so
1628
+ // subsequent registrations see them, but there are no partitions to touch.
1629
+ | ([], true) => fetchState
1574
1630
  | (_, _) => {
1575
1631
  let newPartitions = []
1576
1632
  let dynamicContractsRef = ref(fetchState.optimizedPartitions.dynamicContracts)
1577
1633
  let mutExistingPartitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
1578
1634
 
1579
- for idx in 0 to dcContractNamesToStore->Array.length - 1 {
1580
- let contractName = dcContractNamesToStore->Array.getUnsafe(idx)
1635
+ for idx in 0 to registeringContractNames->Array.length - 1 {
1636
+ let contractName = registeringContractNames->Array.getUnsafe(idx)
1581
1637
 
1582
1638
  // When a new contract name is added as a dynamic contract for the first time (not in dynamicContracts set):
1583
1639
  // Walks through existing partitions that have addresses for this contract name
@@ -1590,12 +1646,12 @@ let registerDynamicContracts = (
1590
1646
 
1591
1647
  for idx in 0 to mutExistingPartitions->Array.length - 1 {
1592
1648
  let p = mutExistingPartitions->Array.getUnsafe(idx)
1593
- switch p.addressesByContractName->Utils.Dict.dangerouslyGetNonOption(contractName) {
1594
- | None => () // Skip partitions which don't have our contract
1595
- | Some(addresses) =>
1649
+ switch p.addresses->AddressSet.countFor(contractName) {
1650
+ | 0 => () // Skip partitions which don't have our contract
1651
+ | _ =>
1596
1652
  // Also filter out partitions which are 100% not mergable
1597
1653
  if p.selection.dependsOnAddresses && p.mergeBlock === None {
1598
- let allPartitionContractNames = p.addressesByContractName->Dict.keysToArray
1654
+ let allPartitionContractNames = p.addresses->AddressSet.contractNames
1599
1655
  switch allPartitionContractNames {
1600
1656
  | [_] =>
1601
1657
  mutExistingPartitions->Array.setUnsafe(
@@ -1618,26 +1674,20 @@ let registerDynamicContracts = (
1618
1674
  (fetchState.optimizedPartitions.nextPartitionIndex +
1619
1675
  newPartitions->Array.length)->Int.toString
1620
1676
 
1621
- let restAddressesByContractName =
1622
- p.addressesByContractName->Utils.Dict.shallowCopy
1623
- restAddressesByContractName->Utils.Dict.deleteInPlace(contractName)
1624
-
1677
+ let restNames =
1678
+ allPartitionContractNames->Array.filter(name => name !== contractName)
1625
1679
  mutExistingPartitions->Array.setUnsafe(
1626
1680
  idx,
1627
- {
1628
- ...p,
1629
- addressesByContractName: restAddressesByContractName,
1630
- },
1681
+ p->withAddresses(p.addresses->AddressSet.filterByContracts(restNames)),
1631
1682
  )
1632
1683
 
1633
- let addressesByContractName = Dict.make()
1634
- addressesByContractName->Dict.set(contractName, addresses)
1684
+ let splitAddresses = p.addresses->AddressSet.filterByContracts([contractName])
1635
1685
  newPartitions->Array.push({
1636
1686
  id: newPartitionId,
1637
1687
  latestFetchedBlock: p.latestFetchedBlock,
1638
1688
  selection: fetchState.normalSelection,
1639
1689
  dynamicContract: Some(contractName),
1640
- addressesByContractName,
1690
+ addresses: splitAddresses,
1641
1691
  mergeBlock: None,
1642
1692
  mutPendingQueries: p.mutPendingQueries,
1643
1693
  sourceRangeCapacity: p.sourceRangeCapacity,
@@ -1652,16 +1702,11 @@ let registerDynamicContracts = (
1652
1702
  }
1653
1703
  }
1654
1704
  }
1655
-
1656
- let registeringContracts = registeringContractsByContract->Dict.getUnsafe(contractName)
1657
- indexingAddresses->IndexingAddresses.register(registeringContracts)
1658
1705
  }
1659
- // Include no-events dcs so later batches detect conflicts against them.
1660
- indexingAddresses->IndexingAddresses.register(noEventsAddresses)
1661
1706
 
1662
1707
  // Switch any dynamic contract that has just crossed the server-side
1663
1708
  // address threshold to client-side filtering. Sticky: the set only grows, and
1664
- // collapse in createPartitionsFromIndexingAddresses folds the contract's
1709
+ // collapse in createPartitions folds the contract's
1665
1710
  // partitions into the single address-free partition.
1666
1711
  // Clone the sticky set before mutating so this update owns its copy and
1667
1712
  // older fetchState snapshots keep theirs (Utils.Set.add mutates in place).
@@ -1674,7 +1719,7 @@ let registerDynamicContracts = (
1674
1719
  dynamicContractsRef.contents
1675
1720
  ->Utils.Set.toArray
1676
1721
  ->Array.forEach(contractName => {
1677
- let addressCount = indexingAddresses->IndexingAddresses.contractCount(~contractName)
1722
+ let addressCount = addressStore->AddressStore.contractCount(contractName)
1678
1723
  if !(clientFilteredContracts->Utils.Set.has(contractName)) && addressCount > threshold {
1679
1724
  clientFilteredContracts->addClientFilteredContract(
1680
1725
  ~contractName,
@@ -1688,8 +1733,19 @@ let registerDynamicContracts = (
1688
1733
  | None => fetchState.optimizedPartitions.clientFilteredContracts
1689
1734
  }
1690
1735
 
1691
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
1692
- ~registeringContractsByContract,
1736
+ // Only this batch's additions need partitions; everything already
1737
+ // registered has one.
1738
+ let registeringSetsByContract = Dict.make()
1739
+ registeringContractNames->Array.forEach(contractName => {
1740
+ registeringSetsByContract->Dict.set(
1741
+ contractName,
1742
+ addressStore->AddressStore.makeSet(~contractName, ~options={minId: idCursor}),
1743
+ )
1744
+ })
1745
+
1746
+ let optimizedPartitions = createPartitions(
1747
+ ~registeringSetsByContract,
1748
+ ~addressStore,
1693
1749
  ~dynamicContracts=dynamicContractsRef.contents,
1694
1750
  ~clientFilteredContracts,
1695
1751
  ~normalSelection=fetchState.normalSelection,
@@ -1698,6 +1754,7 @@ let registerDynamicContracts = (
1698
1754
  newPartitions->Array.length,
1699
1755
  ~existingPartitions=mutExistingPartitions->Array.concat(newPartitions),
1700
1756
  ~progressBlockNumber=0,
1757
+ ~knownHeight=Pervasives.max(claimCeiling, fetchState.knownHeight),
1701
1758
  )
1702
1759
 
1703
1760
  fetchState->updateInternal(~optimizedPartitions)
@@ -1705,33 +1762,6 @@ let registerDynamicContracts = (
1705
1762
  }
1706
1763
  }
1707
1764
 
1708
- // Drop events an address-param filter rejects. A merged partition may
1709
- // over-fetch a wildcard event whose indexed address param references an
1710
- // address registered after the log's block; `clientAddressFilter` is the
1711
- // param-level analogue of the srcAddress effectiveStartBlock check.
1712
- let filterByClientAddress = (
1713
- items: array<Internal.item>,
1714
- ~indexingAddresses: IndexingAddresses.t,
1715
- ): array<Internal.item> =>
1716
- items->Array.filter(item =>
1717
- switch item {
1718
- | Internal.Event({payload, blockNumber}) as item =>
1719
- let reg = (item->Internal.castUnsafeEventItem).onEventRegistration
1720
- switch reg.clientAddressFilter {
1721
- | Some(filter) =>
1722
- filter(
1723
- payload,
1724
- blockNumber,
1725
- indexingAddresses->IndexingAddresses.forContract(
1726
- ~contractName=reg.eventConfig.contractName,
1727
- ),
1728
- )
1729
- | None => true
1730
- }
1731
- | _ => true
1732
- }
1733
- )
1734
-
1735
1765
  /*
1736
1766
  Updates fetchState with a response for a given query.
1737
1767
  Returns Error if the partition with given query cannot be found (unexpected)
@@ -1831,7 +1861,7 @@ let pushDensityPricedQuery = (
1831
1861
  ~density,
1832
1862
  ~chainTargetBlock,
1833
1863
  ~selection,
1834
- ~addressesByContractName,
1864
+ ~addresses,
1835
1865
  ) => {
1836
1866
  let itemsEst = densityItemsTarget(~density, ~fromBlock, ~toBlock, ~chainTargetBlock)
1837
1867
  queries
@@ -1846,7 +1876,7 @@ let pushDensityPricedQuery = (
1846
1876
  | None => Some(itemsEst)
1847
1877
  },
1848
1878
  itemsEst,
1849
- addressesByContractName,
1879
+ addresses,
1850
1880
  })
1851
1881
  ->ignore
1852
1882
  itemsEst
@@ -1875,7 +1905,7 @@ let pushGapFillQueries = (
1875
1905
  ~partition: partition,
1876
1906
  ~partitionBudget: float,
1877
1907
  ~selection: selection,
1878
- ~addressesByContractName: dict<array<Address.t>>,
1908
+ ~addresses: AddressSet.t,
1879
1909
  ) => {
1880
1910
  // Gaps past the chain's target block wait: they regenerate from the
1881
1911
  // pending-walk each tick and fill once the target reaches them. The lagged
@@ -1899,7 +1929,7 @@ let pushGapFillQueries = (
1899
1929
  ~density,
1900
1930
  ~chainTargetBlock,
1901
1931
  ~selection,
1902
- ~addressesByContractName,
1932
+ ~addresses,
1903
1933
  )
1904
1934
  ->ignore
1905
1935
  switch (partition->getTrustedDensity, maybeChunkRange) {
@@ -1921,7 +1951,7 @@ let pushGapFillQueries = (
1921
1951
  ~density,
1922
1952
  ~chainTargetBlock,
1923
1953
  ~selection,
1924
- ~addressesByContractName,
1954
+ ~addresses,
1925
1955
  )
1926
1956
  ->ignore
1927
1957
  chunkFromBlock := chunkToBlock + 1
@@ -1994,7 +2024,7 @@ let walkPartitionPending = (
1994
2024
  ~partition=p,
1995
2025
  ~partitionBudget,
1996
2026
  ~selection=p.selection,
1997
- ~addressesByContractName=p.addressesByContractName,
2027
+ ~addresses=p.addresses,
1998
2028
  )
1999
2029
  chunksUsedThisCall := chunksUsedThisCall.contents + (candidates->Array.length - beforeLen)
2000
2030
  }
@@ -2097,7 +2127,7 @@ let pushForwardCandidates = (
2097
2127
  ~density,
2098
2128
  ~chainTargetBlock,
2099
2129
  ~selection=p.selection,
2100
- ~addressesByContractName=p.addressesByContractName,
2130
+ ~addresses=p.addresses,
2101
2131
  )
2102
2132
  generatedItems := generatedItems.contents +. itemsEst->Int.toFloat
2103
2133
  chunkFromBlock := chunkToBlock + 1
@@ -2132,7 +2162,7 @@ let pushForwardCandidates = (
2132
2162
  // keeps a server cap at its estimate to protect the shared buffer.
2133
2163
  itemsTarget: Some(itemsEst),
2134
2164
  itemsEst,
2135
- addressesByContractName: p.addressesByContractName,
2165
+ addresses: p.addresses,
2136
2166
  })
2137
2167
  ->ignore
2138
2168
  }
@@ -2456,7 +2486,7 @@ let make = (
2456
2486
  ~startBlock,
2457
2487
  ~endBlock,
2458
2488
  ~onEventRegistrations: array<Internal.onEventRegistration>,
2459
- ~contractConfigs: dict<IndexingAddresses.contractConfig>,
2489
+ ~addressStore: AddressStore.t,
2460
2490
  ~addresses: array<Internal.indexingAddress>,
2461
2491
  ~maxAddrInPartition,
2462
2492
  ~chainId,
@@ -2467,6 +2497,7 @@ let make = (
2467
2497
  ~blockLag=0,
2468
2498
  ~firstEventBlock=None,
2469
2499
  ~clientFilterAddressThreshold=None,
2500
+ ~isResumed=false,
2470
2501
  ): t => {
2471
2502
  let latestFetchedBlock = {
2472
2503
  blockTimestamp: 0,
@@ -2496,7 +2527,7 @@ let make = (
2496
2527
  dependsOnAddresses: false,
2497
2528
  onEventRegistrations: notDependingOnAddresses,
2498
2529
  },
2499
- addressesByContractName: Dict.make(),
2530
+ addresses: addressStore->AddressStore.emptySet,
2500
2531
  mergeBlock: None,
2501
2532
  dynamicContract: None,
2502
2533
  mutPendingQueries: [],
@@ -2512,9 +2543,22 @@ let make = (
2512
2543
  onEventRegistrations: normalRegistrations,
2513
2544
  }
2514
2545
 
2515
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
2546
+ // Every address the chain indexes goes into the store — including ones whose
2547
+ // contract has no address-dependent events, so a later registration of the
2548
+ // same address still conflicts and the address is still persisted.
2549
+ addressStore
2550
+ ->AddressStore.registerBatch(
2551
+ addresses->Array.map((contract): AddressStore.registration => {
2552
+ address: contract.address,
2553
+ contractName: contract.contractName,
2554
+ registrationBlock: contract.registrationBlock,
2555
+ }),
2556
+ )
2557
+ ->ignore
2558
+
2516
2559
  let dynamicContracts = Utils.Set.make()
2517
2560
  let clientFilteredContracts = Utils.Set.make()
2561
+ let registeringSetsByContract = Dict.make()
2518
2562
 
2519
2563
  addresses->Array.forEach(contract => {
2520
2564
  let contractName = contract.contractName
@@ -2522,12 +2566,12 @@ let make = (
2522
2566
  // Only addresses whose contract has events that depend on addresses get
2523
2567
  // registered for active fetching via partitions.
2524
2568
  if contractNamesWithNormalEvents->Utils.Set.has(contractName) {
2525
- registeringContractsByContract
2526
- ->Utils.Dict.getOrInsertEmptyDict(contractName)
2527
- ->Dict.set(
2528
- contract.address->Address.toString,
2529
- IndexingAddresses.makeIndexingAddress(~contract, ~contractConfigs),
2530
- )
2569
+ if !(registeringSetsByContract->Dict.has(contractName)) {
2570
+ registeringSetsByContract->Dict.set(
2571
+ contractName,
2572
+ addressStore->AddressStore.makeSet(~contractName),
2573
+ )
2574
+ }
2531
2575
 
2532
2576
  // Detect dynamic contracts by registrationBlock
2533
2577
  if contract.registrationBlock !== -1 {
@@ -2541,22 +2585,24 @@ let make = (
2541
2585
  // address list, or a dynamic contract restored from a large persisted set.
2542
2586
  switch clientFilterAddressThreshold {
2543
2587
  | Some(threshold) =>
2544
- registeringContractsByContract->Utils.Dict.forEachWithKey((addrs, contractName) => {
2545
- let addressCount = addrs->Utils.Dict.size
2588
+ registeringSetsByContract->Utils.Dict.forEachWithKey((set, contractName) => {
2589
+ let addressCount = set->AddressSet.size
2546
2590
  if addressCount > threshold {
2547
2591
  clientFilteredContracts->addClientFilteredContract(
2548
2592
  ~contractName,
2549
2593
  ~chainId,
2550
2594
  ~addressCount,
2551
2595
  ~threshold,
2596
+ ~shouldLog=!isResumed,
2552
2597
  )
2553
2598
  }
2554
2599
  })
2555
2600
  | None => ()
2556
2601
  }
2557
2602
 
2558
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
2559
- ~registeringContractsByContract,
2603
+ let optimizedPartitions = createPartitions(
2604
+ ~registeringSetsByContract,
2605
+ ~addressStore,
2560
2606
  ~dynamicContracts,
2561
2607
  ~clientFilteredContracts,
2562
2608
  ~normalSelection,
@@ -2564,6 +2610,7 @@ let make = (
2564
2610
  ~nextPartitionIndex=partitions->Array.length,
2565
2611
  ~existingPartitions=partitions, // wildcard partition(s) if any
2566
2612
  ~progressBlockNumber,
2613
+ ~knownHeight,
2567
2614
  )
2568
2615
 
2569
2616
  if (
@@ -2604,7 +2651,6 @@ let make = (
2604
2651
 
2605
2652
  let fetchState = {
2606
2653
  optimizedPartitions,
2607
- contractConfigs,
2608
2654
  chainId,
2609
2655
  startBlock,
2610
2656
  endBlock,
@@ -2657,16 +2703,29 @@ Always recreates optimized partitions to avoid duplicate addresses:
2657
2703
  - Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
2658
2704
  - Non-wildcard with lfb > target: delete, track addresses for recreation
2659
2705
  */
2660
- let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetBlockNumber) => {
2661
- // Step 1: Prune addresses registered after the target block. The pruned index is
2662
- // then the source of truth for partition cleanup below — an address survives iff
2663
- // it's still in the index.
2664
- indexingAddresses->IndexingAddresses.rollbackInPlace(~targetBlockNumber)
2706
+ let rollback = (fetchState: t, ~addressStore: AddressStore.t, ~targetBlockNumber) => {
2707
+ // Step 1: Prune addresses registered after the target block. The pruned store
2708
+ // is then the source of truth for partition cleanup below — an address
2709
+ // survives iff `filterByRegistrationBlock` keeps it.
2710
+ addressStore->AddressStore.rollback(targetBlockNumber)->ignore
2665
2711
 
2666
2712
  // Step 2: Categorize partitions
2667
2713
  let keptPartitions = []
2668
2714
  let nextKeptIdRef = ref(0)
2669
- let registeringContractsByContract: dict<dict<indexingAddress>> = Dict.make()
2715
+ let registeringSetsByContract: dict<AddressSet.t> = Dict.make()
2716
+ let collectForRecreation = (set: AddressSet.t) =>
2717
+ set
2718
+ ->AddressSet.contractNames
2719
+ ->Array.forEach(contractName => {
2720
+ let contractSet = set->AddressSet.filterByContracts([contractName])
2721
+ registeringSetsByContract->Dict.set(
2722
+ contractName,
2723
+ switch registeringSetsByContract->Utils.Dict.dangerouslyGetNonOption(contractName) {
2724
+ | Some(existing) => existing->AddressSet.merge(contractSet)
2725
+ | None => contractSet
2726
+ },
2727
+ )
2728
+ })
2670
2729
 
2671
2730
  let partitions = fetchState.optimizedPartitions.entities->Dict.valuesToArray
2672
2731
  for idx in 0 to partitions->Array.length - 1 {
@@ -2683,52 +2742,41 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2683
2742
  latestFetchedBlock: p.latestFetchedBlock.blockNumber > targetBlockNumber
2684
2743
  ? {blockNumber: targetBlockNumber, blockTimestamp: 0}
2685
2744
  : p.latestFetchedBlock,
2745
+ // Everything above the target is refetched by whichever partition this
2746
+ // one was catching up to, so there is nothing left to catch up on past
2747
+ // it. createPartitions drops the partition outright when the capped
2748
+ // block is already reached.
2749
+ mergeBlock: switch p.mergeBlock {
2750
+ | Some(mergeBlock) if mergeBlock > targetBlockNumber => Some(targetBlockNumber)
2751
+ | other => other
2752
+ },
2686
2753
  mutPendingQueries: rollbackPendingQueries(p.mutPendingQueries, ~targetBlockNumber),
2687
2754
  })
2688
2755
  ->ignore
2689
2756
 
2690
2757
  // Non-wildcard with lfb > target: delete, collect addresses for recreation
2691
2758
  | _ if p.latestFetchedBlock.blockNumber > targetBlockNumber =>
2692
- p.addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
2693
- addresses->Array.forEach(address => {
2694
- switch indexingAddresses->IndexingAddresses.get(address->Address.toString) {
2695
- | Some(indexingContract) =>
2696
- let registeringContracts =
2697
- registeringContractsByContract->Utils.Dict.getOrInsertEmptyDict(contractName)
2698
- registeringContracts->Dict.set(address->Address.toString, indexingContract)
2699
- | None => ()
2700
- }
2701
- })
2702
- })
2759
+ collectForRecreation(p.addresses->AddressSet.filterByRegistrationBlock(targetBlockNumber))
2703
2760
 
2704
2761
  // Non-wildcard with lfb <= target: keep, adjust pending queries and mergeBlock
2705
- | {addressesByContractName} => {
2762
+ | _ => {
2706
2763
  // Cap mergeBlock at target
2707
2764
  let mergeBlock = switch p.mergeBlock {
2708
2765
  | Some(mergeBlock) if mergeBlock > targetBlockNumber => Some(targetBlockNumber)
2709
2766
  | other => other
2710
2767
  }
2711
2768
 
2712
- // Drop addresses pruned from the index
2713
- let rollbackedAddressesByContractName = Dict.make()
2714
- addressesByContractName->Utils.Dict.forEachWithKey((addresses, contractName) => {
2715
- let keptAddresses =
2716
- addresses->Array.filter(address =>
2717
- indexingAddresses->IndexingAddresses.get(address->Address.toString)->Option.isSome
2718
- )
2719
- if keptAddresses->Array.length > 0 {
2720
- rollbackedAddressesByContractName->Dict.set(contractName, keptAddresses)
2721
- }
2722
- })
2769
+ // Drop addresses pruned from the store
2770
+ let rollbackedAddresses =
2771
+ p.addresses->AddressSet.filterByRegistrationBlock(targetBlockNumber)
2723
2772
 
2724
- if !(rollbackedAddressesByContractName->Utils.Dict.isEmpty) {
2773
+ if !(rollbackedAddresses->AddressSet.isEmpty) {
2725
2774
  let id = nextKeptIdRef.contents->Int.toString
2726
2775
  nextKeptIdRef := nextKeptIdRef.contents + 1
2727
2776
  keptPartitions
2728
2777
  ->Array.push({
2729
- ...p,
2778
+ ...p->withAddresses(rollbackedAddresses),
2730
2779
  id,
2731
- addressesByContractName: rollbackedAddressesByContractName,
2732
2780
  mutPendingQueries: rollbackPendingQueries(p.mutPendingQueries, ~targetBlockNumber),
2733
2781
  mergeBlock,
2734
2782
  })
@@ -2739,8 +2787,9 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2739
2787
  }
2740
2788
 
2741
2789
  // Step 3: Recreate partitions from deleted partition addresses
2742
- let optimizedPartitions = createPartitionsFromIndexingAddresses(
2743
- ~registeringContractsByContract,
2790
+ let optimizedPartitions = createPartitions(
2791
+ ~registeringSetsByContract,
2792
+ ~addressStore,
2744
2793
  ~dynamicContracts=fetchState.optimizedPartitions.dynamicContracts,
2745
2794
  ~clientFilteredContracts=fetchState.optimizedPartitions.clientFilteredContracts,
2746
2795
  ~normalSelection=fetchState.normalSelection,
@@ -2748,6 +2797,7 @@ let rollback = (fetchState: t, ~indexingAddresses: IndexingAddresses.t, ~targetB
2748
2797
  ~nextPartitionIndex=nextKeptIdRef.contents,
2749
2798
  ~existingPartitions=keptPartitions,
2750
2799
  ~progressBlockNumber=targetBlockNumber,
2800
+ ~knownHeight=fetchState.knownHeight,
2751
2801
  )
2752
2802
 
2753
2803
  // Step 4: Update state