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

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 (129) hide show
  1. package/package.json +6 -6
  2. package/src/Batch.res +8 -8
  3. package/src/Batch.res.mjs +4 -3
  4. package/src/BatchProcessing.res +24 -0
  5. package/src/BatchProcessing.res.mjs +12 -0
  6. package/src/ChainFetching.res +20 -19
  7. package/src/ChainFetching.res.mjs +14 -14
  8. package/src/ChainId.res +58 -0
  9. package/src/ChainId.res.mjs +49 -0
  10. package/src/ChainId.resi +27 -0
  11. package/src/ChainMap.res +12 -26
  12. package/src/ChainMap.res.mjs +5 -41
  13. package/src/ChainMap.resi +5 -20
  14. package/src/ChainMetadata.res +1 -1
  15. package/src/ChainMetadata.res.mjs +3 -2
  16. package/src/ChainState.res +45 -24
  17. package/src/ChainState.res.mjs +40 -16
  18. package/src/ChainState.resi +3 -1
  19. package/src/Config.res +43 -26
  20. package/src/Config.res.mjs +19 -16
  21. package/src/ContractRegisterContext.res +1 -1
  22. package/src/ContractRegisterContext.res.mjs +1 -1
  23. package/src/Core.res +1 -0
  24. package/src/CrossChainState.res +90 -22
  25. package/src/CrossChainState.res.mjs +55 -16
  26. package/src/CrossChainState.resi +5 -1
  27. package/src/Env.res +0 -3
  28. package/src/Env.res.mjs +0 -3
  29. package/src/EventConfigBuilder.res +1 -1
  30. package/src/EventProcessing.res +4 -4
  31. package/src/EventProcessing.res.mjs +5 -5
  32. package/src/FetchState.res +489 -153
  33. package/src/FetchState.res.mjs +399 -129
  34. package/src/FinalizeBackfill.res +61 -0
  35. package/src/FinalizeBackfill.res.mjs +44 -0
  36. package/src/HandlerRegister.res +8 -8
  37. package/src/HandlerRegister.res.mjs +5 -4
  38. package/src/HandlerRegister.resi +1 -1
  39. package/src/InMemoryStore.res +4 -0
  40. package/src/InMemoryStore.res.mjs +1 -1
  41. package/src/InMemoryTable.res +15 -15
  42. package/src/InMemoryTable.res.mjs +14 -14
  43. package/src/IndexerLoop.res +9 -2
  44. package/src/IndexerLoop.res.mjs +8 -1
  45. package/src/IndexerState.res +52 -23
  46. package/src/IndexerState.res.mjs +52 -14
  47. package/src/IndexerState.resi +11 -6
  48. package/src/Internal.res +19 -17
  49. package/src/Internal.res.mjs +18 -9
  50. package/src/LoadLayer.res +10 -2
  51. package/src/LoadLayer.res.mjs +3 -1
  52. package/src/LogSelection.res +2 -2
  53. package/src/Main.res +7 -11
  54. package/src/Main.res.mjs +4 -6
  55. package/src/Metrics.res +6 -6
  56. package/src/Metrics.res.mjs +4 -3
  57. package/src/Persistence.res +23 -4
  58. package/src/PgStorage.res +447 -100
  59. package/src/PgStorage.res.mjs +285 -99
  60. package/src/RawEvent.res +1 -2
  61. package/src/RawEvent.res.mjs +1 -1
  62. package/src/Rollback.res +18 -20
  63. package/src/Rollback.res.mjs +9 -9
  64. package/src/RollbackCommit.res +2 -2
  65. package/src/RollbackCommit.res.mjs +3 -3
  66. package/src/SafeCheckpointTracking.res +2 -2
  67. package/src/SimulateDeadInputTracker.res +17 -21
  68. package/src/SimulateDeadInputTracker.res.mjs +9 -8
  69. package/src/SimulateItems.res +8 -10
  70. package/src/SimulateItems.res.mjs +9 -10
  71. package/src/Sink.res +2 -2
  72. package/src/Sink.res.mjs +3 -2
  73. package/src/TestIndexer.res +14 -14
  74. package/src/TestIndexer.res.mjs +16 -10
  75. package/src/UserContext.res +2 -2
  76. package/src/UserContext.res.mjs +2 -1
  77. package/src/Utils.res +0 -4
  78. package/src/Utils.res.mjs +1 -6
  79. package/src/bindings/ClickHouse.res +20 -2
  80. package/src/bindings/ClickHouse.res.mjs +25 -14
  81. package/src/db/EntityFilter.res +0 -4
  82. package/src/db/EntityFilter.res.mjs +1 -8
  83. package/src/db/EntityHistory.res +1 -1
  84. package/src/db/IndexCatalog.res +248 -0
  85. package/src/db/IndexCatalog.res.mjs +222 -0
  86. package/src/db/IndexDefinition.res +131 -0
  87. package/src/db/IndexDefinition.res.mjs +138 -0
  88. package/src/db/IndexManager.res +145 -0
  89. package/src/db/IndexManager.res.mjs +130 -0
  90. package/src/db/InternalTable.res +54 -23
  91. package/src/db/InternalTable.res.mjs +38 -23
  92. package/src/db/Table.res +24 -14
  93. package/src/db/Table.res.mjs +21 -16
  94. package/src/sources/AddressSet.res +5 -4
  95. package/src/sources/AddressStore.res +25 -14
  96. package/src/sources/AddressStore.res.mjs +10 -12
  97. package/src/sources/Evm.res +2 -2
  98. package/src/sources/Evm.res.mjs +1 -1
  99. package/src/sources/EvmChain.res +3 -3
  100. package/src/sources/EvmChain.res.mjs +3 -3
  101. package/src/sources/EvmHyperSyncSource.res +7 -7
  102. package/src/sources/EvmHyperSyncSource.res.mjs +6 -6
  103. package/src/sources/Fuel.res +2 -2
  104. package/src/sources/Fuel.res.mjs +1 -1
  105. package/src/sources/FuelHyperSyncClient.res +3 -0
  106. package/src/sources/FuelHyperSyncClient.res.mjs +1 -0
  107. package/src/sources/FuelHyperSyncSource.res +5 -7
  108. package/src/sources/FuelHyperSyncSource.res.mjs +5 -5
  109. package/src/sources/HyperSync.resi +2 -2
  110. package/src/sources/HyperSyncClient.res +5 -0
  111. package/src/sources/HyperSyncClient.res.mjs +1 -0
  112. package/src/sources/RpcSource.res +13 -14
  113. package/src/sources/RpcSource.res.mjs +13 -12
  114. package/src/sources/SimulateSource.res +42 -15
  115. package/src/sources/SimulateSource.res.mjs +27 -9
  116. package/src/sources/Source.res +1 -5
  117. package/src/sources/SourceManager.res +12 -12
  118. package/src/sources/SourceManager.res.mjs +11 -10
  119. package/src/sources/SourceManager.resi +2 -2
  120. package/src/sources/Svm.res +4 -5
  121. package/src/sources/Svm.res.mjs +5 -4
  122. package/src/sources/SvmHyperSyncClient.res +3 -0
  123. package/src/sources/SvmHyperSyncClient.res.mjs +1 -0
  124. package/src/sources/SvmHyperSyncSource.res +4 -4
  125. package/src/sources/SvmHyperSyncSource.res.mjs +3 -3
  126. package/src/tui/Tui.res +1 -1
  127. package/src/tui/Tui.res.mjs +2 -1
  128. package/src/tui/components/CustomHooks.res +5 -4
  129. package/src/tui/components/CustomHooks.res.mjs +4 -2
@@ -19,6 +19,42 @@ type selection = {
19
19
  // gates each item against the chain's address store instead of the
20
20
  // partition's set. Absent for normal partitions.
21
21
  clientFilteredContracts?: array<string>,
22
+ // The earliest block any of these registrations can produce an item at.
23
+ // Derived once here, by `makeSelection`, because `getNextQuery` reads it for
24
+ // every partition on every tick and a chain can hold hundreds of them.
25
+ //
26
+ // Absent when a registration is unrestricted and so can fire from the chain
27
+ // start — which is also the only case where nothing can be skipped, so absent
28
+ // and "block 0" are the same answer here. Build selections through
29
+ // `makeSelection` rather than as literals, or this goes stale.
30
+ startBlock?: int,
31
+ }
32
+
33
+ // The earliest block any of the registrations can produce an item at, or `None`
34
+ // when one of them is unrestricted. Nothing below it is worth querying.
35
+ let deriveSelectionStartBlock = (onEventRegistrations: array<Internal.onEventRegistration>) => {
36
+ let earliest = ref(None)
37
+ let idx = ref(0)
38
+ let unrestricted = ref(onEventRegistrations->Utils.Array.isEmpty)
39
+ while !unrestricted.contents && idx.contents < onEventRegistrations->Array.length {
40
+ switch (onEventRegistrations->Array.getUnsafe(idx.contents)).startBlock {
41
+ | None => unrestricted := true
42
+ | Some(startBlock) =>
43
+ switch earliest.contents {
44
+ | Some(current) if current <= startBlock => ()
45
+ | _ => earliest := Some(startBlock)
46
+ }
47
+ }
48
+ idx := idx.contents + 1
49
+ }
50
+ unrestricted.contents ? None : earliest.contents
51
+ }
52
+
53
+ let makeSelection = (~onEventRegistrations, ~dependsOnAddresses, ~clientFilteredContracts=?) => {
54
+ onEventRegistrations,
55
+ dependsOnAddresses,
56
+ ?clientFilteredContracts,
57
+ startBlock: ?deriveSelectionStartBlock(onEventRegistrations),
22
58
  }
23
59
 
24
60
  type pendingQuery = {
@@ -100,6 +136,43 @@ type query = {
100
136
 
101
137
  let withAddresses = (p: partition, addresses: AddressSet.t) => {...p, addresses}
102
138
 
139
+ // The selection a query over [fromBlock, toBlock] actually needs: a registration
140
+ // whose own start block sits past the range can't produce an item there, so
141
+ // leaving it out keeps the source from asking the server for its logs at all.
142
+ // The address gate can't express this — it's contract-wide, so an unrestricted
143
+ // sibling holds it open from the chain start for every registration alike.
144
+ //
145
+ // Returns the selection as-is when nothing is dropped (the common case), and
146
+ // for an open-ended query, which may reach any block and so can exclude nothing.
147
+ // Also when every registration would be dropped: `selectionStartBlock` keeps a
148
+ // partition's cursor at or above its earliest start block, so a query below all
149
+ // of them shouldn't exist — and an empty selection is one no source can build.
150
+ let narrowSelectionToRange = (selection: selection, ~toBlock) =>
151
+ switch toBlock {
152
+ | None => selection
153
+ | Some(toBlock) =>
154
+ let inRange = selection.onEventRegistrations->Array.filter(reg =>
155
+ switch reg.startBlock {
156
+ | Some(startBlock) => startBlock <= toBlock
157
+ | None => true
158
+ }
159
+ )
160
+ switch inRange->Array.length {
161
+ // Every registration starts above the range. Narrowing to nothing would
162
+ // build a selection with no log filters, which each source reads as
163
+ // "select everything" — the opposite of the intent. The partition is
164
+ // queried unnarrowed instead and the routers drop what hasn't started.
165
+ | 0 => selection
166
+ | length if length === selection.onEventRegistrations->Array.length => selection
167
+ | _ =>
168
+ makeSelection(
169
+ ~onEventRegistrations=inRange,
170
+ ~dependsOnAddresses=selection.dependsOnAddresses,
171
+ ~clientFilteredContracts=?selection.clientFilteredContracts,
172
+ )
173
+ }
174
+ }
175
+
103
176
  // itemsEst for a query over [fromBlock, toBlock] at the given event density
104
177
  // (items/block). toBlock None is the open-ended tail, capped at
105
178
  // chainTargetBlock — the soft per-tick horizon the owning chain wants to reach
@@ -127,6 +200,14 @@ let getMinHistoryRange = (p: partition) => {
127
200
  // source-capacity history, including when an itemsTarget cap truncates a response.
128
201
  let getTrustedDensity = (p: partition) => p.eventDensity
129
202
 
203
+ // A response is still owed for this partition, so it owns range nothing else
204
+ // accounts for: removing it would let the frontier advance over blocks the
205
+ // response can still deliver items from, and orphan the response itself.
206
+ // Narrower than a non-empty queue on purpose — a settled query parked behind a
207
+ // gap is data already in hand, and waiting on it would wedge a partition that
208
+ // no longer queries.
209
+ let isFetching = (p: partition) => p.mutPendingQueries->Array.some(pq => pq.fetchedBlock === None)
210
+
130
211
  let getMinQueryRange = (partitions: array<partition>) => {
131
212
  let min = ref(0)
132
213
  for i in 0 to partitions->Array.length - 1 {
@@ -270,6 +351,47 @@ module OptimizedPartitions = {
270
351
  let ascSortFn = (a, b) =>
271
352
  Int.compare(a.latestFetchedBlock.blockNumber, b.latestFetchedBlock.blockNumber)
272
353
 
354
+ // Contracts a standing address-free partition already fetches client-side.
355
+ // Addresses registered for them after that partition passed get a normal
356
+ // address-bound partition which dies once it catches up (see make), instead
357
+ // of being folded into the address-free one.
358
+ let anchoredContracts = (partitions: array<partition>) => {
359
+ let set = Utils.Set.make()
360
+ partitions->Array.forEach(p =>
361
+ switch (p.mergeBlock, p.selection.clientFilteredContracts) {
362
+ | (None, Some(contractNames)) =>
363
+ contractNames->Array.forEach(name => set->Utils.Set.add(name)->ignore)
364
+ | _ => ()
365
+ }
366
+ )
367
+ set
368
+ }
369
+
370
+ // The furthest block an address-free partition's already-dispatched queries
371
+ // can deliver without the addresses registered from now on. A settled query
372
+ // claims only what it actually fetched — the rest of its range is re-queried
373
+ // later, by then against the updated address store — while an in-flight
374
+ // bounded query claims its whole toBlock. An in-flight unbounded query has no
375
+ // ceiling at all, so nothing is safe to stop a catch-up partition at yet.
376
+ let getAnchorSafeBlock = (p: partition) => {
377
+ let safeRef = ref(Some(p.latestFetchedBlock.blockNumber))
378
+ p.mutPendingQueries->Array.forEach(pq =>
379
+ switch (safeRef.contents, pq) {
380
+ | (None, _) => ()
381
+ | (Some(safe), {fetchedBlock: Some({blockNumber})}) =>
382
+ if blockNumber > safe {
383
+ safeRef := Some(blockNumber)
384
+ }
385
+ | (Some(safe), {toBlock: Some(toBlock)}) =>
386
+ if toBlock > safe {
387
+ safeRef := Some(toBlock)
388
+ }
389
+ | (Some(_), _) => safeRef := None
390
+ }
391
+ )
392
+ safeRef.contents
393
+ }
394
+
273
395
  /**
274
396
  * Optimizes partitions by finding opportunities to merge partitions that
275
397
  * are behind other partitions with same/superset of contract names.
@@ -288,19 +410,50 @@ module OptimizedPartitions = {
288
410
  let mergingPartitions = Dict.make()
289
411
  let nextPartitionIndexRef = ref(nextPartitionIndex)
290
412
 
413
+ // Where a catch-up partition for a client-filtered contract may stop, keyed
414
+ // by contract name. A contract absent from the dict while present in
415
+ // anchoredContractsSet has an unbounded query in flight on its address-free
416
+ // partition — its catch-up can't be bounded yet, and a later call will bound it.
417
+ let anchorSafeBlocks = Dict.make()
418
+ let anchoredContractsSet = Utils.Set.make()
419
+ if clientFilteredContracts->Utils.Set.size > 0 {
420
+ partitions->Array.forEach(p =>
421
+ switch (p.mergeBlock, p.selection.clientFilteredContracts) {
422
+ | (None, Some(contractNames)) =>
423
+ let safeBlock = p->getAnchorSafeBlock
424
+ contractNames->Array.forEach(name => {
425
+ anchoredContractsSet->Utils.Set.add(name)->ignore
426
+ switch safeBlock {
427
+ | Some(safeBlock) => anchorSafeBlocks->Dict.set(name, safeBlock)
428
+ | None => ()
429
+ }
430
+ })
431
+ | _ => ()
432
+ }
433
+ )
434
+ }
435
+
291
436
  for idx in 0 to partitions->Array.length - 1 {
292
437
  let p = partitions->Array.getUnsafe(idx)
293
438
  switch p {
439
+ // For now don't merge partitions with mergeBlock,
440
+ // assuming they are already merged,
441
+ // TODO: Although there might be cases with too far away mergeBlock,
442
+ // which is worth merging.
443
+ // A partition already at or past its merge block is done — normally
444
+ // handleQueryResponse removes it when the response lands, but a rollback
445
+ // can cap the merge block at the rolled-back frontier, leaving no
446
+ // response to ever remove it on. A retired partition still awaiting a
447
+ // response stays until it lands.
448
+ | {mergeBlock: Some(mergeBlock)} =>
449
+ if p.latestFetchedBlock.blockNumber < mergeBlock || p->isFetching {
450
+ newPartitions->Array.push(p)->ignore
451
+ }
294
452
  // Since it's not a dynamic contract partition,
295
453
  // there's no need for merge logic
296
454
  | {dynamicContract: None}
297
455
  | // Wildcard doesn't need merging
298
- {selection: {dependsOnAddresses: false}}
299
- | // For now don't merge partitions with mergeBlock,
300
- // assuming they are already merged,
301
- // TODO: Although there might be cases with too far away mergeBlock,
302
- // which is worth merging
303
- {mergeBlock: Some(_)} =>
456
+ {selection: {dependsOnAddresses: false}} =>
304
457
  newPartitions->Array.push(p)->ignore
305
458
  | {dynamicContract: Some(contractName)} =>
306
459
  let pAddressesCount = p.addresses->AddressSet.countFor(contractName)
@@ -398,14 +551,46 @@ module OptimizedPartitions = {
398
551
  newPartitions->Array.push(currentPRef.contents)->ignore
399
552
  }
400
553
 
554
+ // A dynamic partition for a contract the address-free partition already
555
+ // fetches is a catch-up for addresses registered after that partition
556
+ // passed them. It merges by disappearing once it reaches the last block
557
+ // that partition's dispatched queries could have missed them on — its
558
+ // addresses are in the chain's store, so there is nothing to hand over.
559
+ let finalPartitions = if anchoredContractsSet->Utils.Set.size === 0 {
560
+ newPartitions
561
+ } else {
562
+ let anchored = []
563
+ newPartitions->Array.forEach(p =>
564
+ switch p {
565
+ | {dynamicContract: Some(contractName), mergeBlock: None}
566
+ if anchoredContractsSet->Utils.Set.has(contractName) =>
567
+ switch anchorSafeBlocks->Utils.Dict.dangerouslyGetNonOption(contractName) {
568
+ | None => anchored->Array.push(p)->ignore
569
+ | Some(anchorSafeBlock) =>
570
+ if p.latestFetchedBlock.blockNumber < anchorSafeBlock {
571
+ anchored->Array.push({...p, mergeBlock: Some(anchorSafeBlock)})->ignore
572
+ } else if p->isFetching {
573
+ // Caught up, but a response is still owed: keep it until that
574
+ // lands rather than dropping the range it owns.
575
+ anchored
576
+ ->Array.push({...p, mergeBlock: Some(p.latestFetchedBlock.blockNumber)})
577
+ ->ignore
578
+ }
579
+ }
580
+ | _ => anchored->Array.push(p)->ignore
581
+ }
582
+ )
583
+ anchored
584
+ }
585
+
401
586
  // Sort partitions by latestFetchedBlock ascending
402
- let _ = newPartitions->Array.sort(ascSortFn)
587
+ let _ = finalPartitions->Array.sort(ascSortFn)
403
588
 
404
- let partitionsCount = newPartitions->Array.length
589
+ let partitionsCount = finalPartitions->Array.length
405
590
  let idsInAscOrder = Utils.Array.jsArrayCreate(partitionsCount)
406
591
  let entities = Dict.make()
407
592
  for idx in 0 to partitionsCount - 1 {
408
- let p = newPartitions->Array.getUnsafe(idx)
593
+ let p = finalPartitions->Array.getUnsafe(idx)
409
594
  idsInAscOrder->Array.setUnsafe(idx, p.id)
410
595
  entities->Dict.set(p.id, p)
411
596
  }
@@ -468,6 +653,10 @@ module OptimizedPartitions = {
468
653
  }
469
654
  }
470
655
 
656
+ // Every path that stops a partition from fetching keeps it until its last
657
+ // response lands, so a response always has a partition to advance. A rollback
658
+ // does delete partitions mid-run, but it bumps the indexer epoch and
659
+ // ChainFetching drops older responses before they reach here.
471
660
  let rec handleQueryResponse = (
472
661
  optimizedPartitions: t,
473
662
  ~query,
@@ -475,21 +664,13 @@ module OptimizedPartitions = {
475
664
  ~itemsCount,
476
665
  ~latestFetchedBlock: blockNumberAndTimestamp,
477
666
  ) =>
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
- }
667
+ optimizedPartitions->handleQueryResponseForPartition(
668
+ ~p=optimizedPartitions->getOrThrow(~partitionId=query.partitionId),
669
+ ~query,
670
+ ~knownHeight,
671
+ ~itemsCount,
672
+ ~latestFetchedBlock,
673
+ )
493
674
 
494
675
  and handleQueryResponseForPartition = (
495
676
  optimizedPartitions: t,
@@ -562,11 +743,15 @@ module OptimizedPartitions = {
562
743
  ~initialLatestFetchedBlock=p.latestFetchedBlock,
563
744
  )
564
745
 
565
- // Check if partition reached its mergeBlock and should be removed
566
- let partitionReachedMergeBlock = switch p.mergeBlock {
567
- | Some(mergeBlock) => updatedLatestFetchedBlock.blockNumber >= mergeBlock
568
- | None => false
569
- }
746
+ // Check if partition reached its mergeBlock and should be removed. A
747
+ // retired partition can hold several queries at once, so it goes only when
748
+ // the last of them has landed.
749
+ let partitionReachedMergeBlock =
750
+ switch p.mergeBlock {
751
+ | Some(mergeBlock) => updatedLatestFetchedBlock.blockNumber >= mergeBlock
752
+ | None => false
753
+ } &&
754
+ !(p->isFetching)
570
755
 
571
756
  if partitionReachedMergeBlock {
572
757
  mutEntities->Utils.Dict.deleteInPlace(p.id)
@@ -648,7 +833,7 @@ type t = {
648
833
  endBlock: option<int>,
649
834
  normalSelection: selection,
650
835
  // Not used for logic - only metadata
651
- chainId: int,
836
+ chainId: ChainId.t,
652
837
  // The block number of the latest block which was added to the queue
653
838
  // by the onBlock configs
654
839
  // Need a separate pointer for this
@@ -951,18 +1136,24 @@ let addClientFilteredContract = (
951
1136
  ~chainId,
952
1137
  ~addressCount,
953
1138
  ~threshold,
1139
+ // A resumed fetch state re-derives the switch from the persisted addresses,
1140
+ // but it already happened - and was logged - in the run that crossed the
1141
+ // threshold.
1142
+ ~shouldLog=true,
954
1143
  ) => {
955
1144
  clientFilteredContracts->Utils.Set.add(contractName)->ignore
956
- Logging.createChild(
957
- ~params={
958
- "chainId": chainId,
959
- "contractName": contractName,
960
- "addressCount": addressCount,
961
- "threshold": threshold,
962
- },
963
- )->Logging.childTrace(
964
- "Switching contract to client-side address filtering: registered address count crossed the server-side threshold.",
965
- )
1145
+ if shouldLog {
1146
+ Logging.createChild(
1147
+ ~params={
1148
+ "chainId": chainId,
1149
+ "contractName": contractName,
1150
+ "addressCount": addressCount,
1151
+ "threshold": threshold,
1152
+ },
1153
+ )->Logging.childTrace(
1154
+ "Switching contract to client-side address filtering: registered address count crossed the server-side threshold.",
1155
+ )
1156
+ }
966
1157
  }
967
1158
 
968
1159
  // The block a partition will have fetched to once everything on its pending
@@ -987,9 +1178,10 @@ let claimedFetchedBlock = (p: partition, ~knownHeight) =>
987
1178
  // fetching without tearing down established state:
988
1179
  // - The standing address-free partition (no mergeBlock) keeps its id, frontier,
989
1180
  // 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.
1181
+ // must be added does its selection change — under a fresh id, so responses
1182
+ // built from the old selection can't advance the frontier past ranges the new
1183
+ // contract wasn't fetched for. The old generation is retired beside it,
1184
+ // keeping the queue those responses belong to.
993
1185
  // - Partitions absorbed below the standing partition's claimed block
994
1186
  // (single-contract dynamic partitions and config partitions all of whose
995
1187
  // contracts are client-filtered, plus any prior backfill) become one bounded
@@ -998,6 +1190,10 @@ let claimedFetchedBlock = (p: partition, ~knownHeight) =>
998
1190
  // it on arrival. The overlap it re-delivers is deduped by mergeIntoBuffer, and
999
1191
  // the re-fetch doubles as history for freshly registered addresses: events
1000
1192
  // dropped before the address was registered now pass the address gate.
1193
+ // - A dynamic partition for a contract the standing partition already covers is
1194
+ // left alone: it is a catch-up for addresses registered after that partition
1195
+ // passed them, and OptimizedPartitions.make bounds it against the standing
1196
+ // partition's own claims instead.
1001
1197
  // - A partition mixing client-filtered and server-side contracts stays,
1002
1198
  // stripped of the client-filtered contracts' addresses — the address-free
1003
1199
  // partition covers those logs, so fetching them server-side too would only
@@ -1014,6 +1210,10 @@ let collapseClientFilteredContracts = (
1014
1210
  ~normalSelection: selection,
1015
1211
  ~nextPartitionIndexRef: ref<int>,
1016
1212
  ~addressStore: AddressStore.t,
1213
+ // Frontiers of the addresses that were never given a server-side partition
1214
+ // because their contract is already client-filtered. They stand in for the
1215
+ // partitions that would otherwise have been created only to be absorbed here.
1216
+ ~clientFilteredFrontiers: array<blockNumberAndTimestamp>=[],
1017
1217
  ~knownHeight: int,
1018
1218
  ) => {
1019
1219
  if clientFilteredContracts->Utils.Set.size === 0 {
@@ -1024,6 +1224,22 @@ let collapseClientFilteredContracts = (
1024
1224
  let backfills = []
1025
1225
  let absorbedPartitions = []
1026
1226
  let strippedFrontiers = []
1227
+ let anchoredContracts = partitions->OptimizedPartitions.anchoredContracts
1228
+
1229
+ // Retire a partition in place of removing it: same id and selection, same
1230
+ // pending queue, capped at its own frontier so it issues nothing more. It
1231
+ // holds the frontier down and owns its in-flight response until that lands,
1232
+ // then handleQueryResponse drops it.
1233
+ let retire = (p: partition) => {
1234
+ ...p,
1235
+ mergeBlock: Some(p.latestFetchedBlock.blockNumber),
1236
+ }
1237
+ let absorb = p => {
1238
+ absorbedPartitions->Array.push(p)->ignore
1239
+ if p->isFetching {
1240
+ kept->Array.push(p->retire)->ignore
1241
+ }
1242
+ }
1027
1243
 
1028
1244
  partitions->Array.forEach(p =>
1029
1245
  switch p {
@@ -1032,15 +1248,33 @@ let collapseClientFilteredContracts = (
1032
1248
  standingRef := Some(p)
1033
1249
  | {selection: {dependsOnAddresses: false}, mergeBlock: Some(_)} =>
1034
1250
  backfills->Array.push(p)->ignore
1035
- | {selection: {dependsOnAddresses: false}} => absorbedPartitions->Array.push(p)->ignore
1251
+
1252
+ // The backfill this call builds covers its remaining range from the
1253
+ // same min frontier, so it is superseded — but not before the response
1254
+ // it is waiting on lands.
1255
+ if p->isFetching {
1256
+ kept->Array.push(p->retire)->ignore
1257
+ }
1258
+ | {selection: {dependsOnAddresses: false}} => p->absorb
1036
1259
  | _ =>
1037
1260
  let contractNames = p.addresses->AddressSet.contractNames
1038
1261
  let serverSideNames =
1039
1262
  contractNames->Array.filter(c => !(clientFilteredContracts->Utils.Set.has(c)))
1040
1263
  if serverSideNames->Array.length === contractNames->Array.length {
1041
1264
  kept->Array.push(p)->ignore
1265
+ } else if (
1266
+ switch p.dynamicContract {
1267
+ | Some(contractName) => anchoredContracts->Utils.Set.has(contractName)
1268
+ | None => false
1269
+ }
1270
+ ) {
1271
+ // A catch-up partition for addresses registered after the address-free
1272
+ // partition already covered their contract. It is the correctness
1273
+ // barrier for those addresses until it catches up — absorbing it would
1274
+ // hand that job back to a guessed ceiling.
1275
+ kept->Array.push(p)->ignore
1042
1276
  } else if serverSideNames->Utils.Array.isEmpty {
1043
- absorbedPartitions->Array.push(p)->ignore
1277
+ p->absorb
1044
1278
  } else {
1045
1279
  strippedFrontiers->Array.push(p.latestFetchedBlock)->ignore
1046
1280
  kept
@@ -1060,6 +1294,7 @@ let collapseClientFilteredContracts = (
1060
1294
  if (
1061
1295
  absorbedPartitions->Utils.Array.isEmpty &&
1062
1296
  strippedFrontiers->Utils.Array.isEmpty &&
1297
+ clientFilteredFrontiers->Utils.Array.isEmpty &&
1063
1298
  !selectionChanged
1064
1299
  ) {
1065
1300
  // Nothing to fold in and no newly-switched contract: leave the standing
@@ -1075,6 +1310,7 @@ let collapseClientFilteredContracts = (
1075
1310
  absorbedPartitions->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1076
1311
  backfills->Array.forEach(p => considerFrontier(p.latestFetchedBlock))
1077
1312
  strippedFrontiers->Array.forEach(considerFrontier)
1313
+ clientFilteredFrontiers->Array.forEach(considerFrontier)
1078
1314
 
1079
1315
  let regByIndex = Dict.make()
1080
1316
  let addRegs = (regs: array<Internal.onEventRegistration>) =>
@@ -1093,11 +1329,11 @@ let collapseClientFilteredContracts = (
1093
1329
  clientFilteredContracts->Utils.Set.has(reg.eventConfig.contractName)
1094
1330
  ),
1095
1331
  )
1096
- let newSelection = {
1097
- dependsOnAddresses: false,
1098
- onEventRegistrations: regByIndex->Dict.valuesToArray,
1099
- clientFilteredContracts: clientFilteredContracts->Utils.Set.toArray,
1100
- }
1332
+ let newSelection = makeSelection(
1333
+ ~dependsOnAddresses=false,
1334
+ ~onEventRegistrations=regByIndex->Dict.valuesToArray,
1335
+ ~clientFilteredContracts=clientFilteredContracts->Utils.Set.toArray,
1336
+ )
1101
1337
 
1102
1338
  switch standingRef.contents {
1103
1339
  | None =>
@@ -1131,6 +1367,15 @@ let collapseClientFilteredContracts = (
1131
1367
  let standingOut = if selectionChanged {
1132
1368
  let id = nextPartitionIndexRef.contents->Int.toString
1133
1369
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1370
+
1371
+ // A response built from the old selection must not advance the new
1372
+ // one's frontier over ranges the newly-switched contract wasn't
1373
+ // fetched for, so the continuing partition takes a fresh id and an
1374
+ // empty queue. The old generation is retired beside it rather than
1375
+ // dropped: it keeps the queue those responses belong to.
1376
+ if standing->isFetching {
1377
+ kept->Array.push(standing->retire)->ignore
1378
+ }
1134
1379
  {...standing, id, selection: newSelection, mutPendingQueries: []}
1135
1380
  } else {
1136
1381
  standing
@@ -1172,6 +1417,65 @@ let collapseClientFilteredContracts = (
1172
1417
  }
1173
1418
  }
1174
1419
 
1420
+ let warnAddressRegistration = (
1421
+ ~chainId: ChainId.t,
1422
+ ~contractAddress: Address.t,
1423
+ ~params,
1424
+ message,
1425
+ ) =>
1426
+ Logging.createChild(
1427
+ ~params={
1428
+ "chainId": chainId,
1429
+ "contractAddress": contractAddress->Address.toString,
1430
+ "details": params,
1431
+ },
1432
+ )->Logging.childWarn(message)
1433
+
1434
+ // A rejected registration is simply absent from every partition, so without a
1435
+ // warning the user sees a contract that never indexes and nothing saying why.
1436
+ // Shared by config-time registration in `make` and by dynamic registration.
1437
+ let warnRejectedRegistration = (
1438
+ verdict: AddressStore.verdict,
1439
+ ~chainId: ChainId.t,
1440
+ ~contractAddress: Address.t,
1441
+ ~contractName: string,
1442
+ ) =>
1443
+ switch verdict {
1444
+ | Conflict({existingContractName}) =>
1445
+ warnAddressRegistration(
1446
+ ~chainId,
1447
+ ~contractAddress,
1448
+ ~params={
1449
+ "existingContractType": existingContractName,
1450
+ "newContractType": contractName,
1451
+ },
1452
+ `Skipping contract registration: Contract address is already registered for one contract and cannot be registered for another contract.`,
1453
+ )
1454
+ | Duplicate({effectiveStartBlock, existingEffectiveStartBlock}) =>
1455
+ // FIXME: Instead of filtering out duplicates, we should check the block
1456
+ // number first. If a new registration has an earlier block number we
1457
+ // should register it for the missing block range.
1458
+ if existingEffectiveStartBlock > effectiveStartBlock {
1459
+ warnAddressRegistration(
1460
+ ~chainId,
1461
+ ~contractAddress,
1462
+ ~params={
1463
+ "existingBlockNumber": existingEffectiveStartBlock,
1464
+ "newBlockNumber": effectiveStartBlock,
1465
+ },
1466
+ `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.`,
1467
+ )
1468
+ }
1469
+ | Invalid =>
1470
+ warnAddressRegistration(
1471
+ ~chainId,
1472
+ ~contractAddress,
1473
+ ~params={"contractName": contractName},
1474
+ `Skipping contract registration: Not a valid address for this chain's ecosystem.`,
1475
+ )
1476
+ | Added(_) | NoEvents(_) => ()
1477
+ }
1478
+
1175
1479
  /**
1176
1480
  Creates partitions from indexing addresses with two phases:
1177
1481
  Phase 1: Create per-contract-name partitions (smart grouping by startBlock)
@@ -1199,68 +1503,96 @@ OptimizedPartitions.t => {
1199
1503
  // ── Phase 1: Create per-contract-name partitions ──
1200
1504
  let dynamicPartitions = []
1201
1505
  let nonDynamicPartitions = []
1506
+ let clientFilteredFrontiers = []
1507
+
1508
+ // Contracts an address-free partition already fetches. Their new addresses
1509
+ // need a partition of their own: the address-free partition passed the blocks
1510
+ // they were registered at without them in the store.
1511
+ let anchoredContracts = existingPartitions->OptimizedPartitions.anchoredContracts
1202
1512
 
1203
1513
  let contractNames = registeringSetsByContract->Dict.keysToArray
1204
1514
  for cIdx in 0 to contractNames->Array.length - 1 {
1205
1515
  let contractName = contractNames->Array.getUnsafe(cIdx)
1206
1516
  let contractSet = registeringSetsByContract->Dict.getUnsafe(contractName)
1207
1517
 
1208
- let isDynamic = dynamicContracts->Utils.Set.has(contractName)
1518
+ let isAnchored = anchoredContracts->Utils.Set.has(contractName)
1519
+ // A catch-up partition must go through the dynamic merge logic — that's
1520
+ // what bounds it against its anchor and removes it once it catches up.
1521
+ let isDynamic = dynamicContracts->Utils.Set.has(contractName) || isAnchored
1209
1522
  let partitions = isDynamic ? dynamicPartitions : nonDynamicPartitions
1210
1523
 
1211
1524
  // A set is ordered by effectiveStartBlock, so its start-block groups are
1212
1525
  // ascending and each group's addresses are a contiguous slice.
1213
1526
  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
1231
- }
1232
- }
1233
1527
 
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,
1528
+ if clientFilteredContracts->Utils.Set.has(contractName) && !isAnchored {
1529
+ // The contract is switching to client-side filtering in this very call, so
1530
+ // the collapse below folds everything it has into the address-free
1531
+ // partition anyway. Chunking these addresses by maxAddrInPartition would
1532
+ // only build partitions for it to absorb - hundreds of them for a contract
1533
+ // big enough to be client-filtered in the first place. All the collapse
1534
+ // needs is the earliest block the addresses aren't covered from, which is
1535
+ // the first group's since groups are ascending.
1536
+ switch groups->Array.get(0) {
1537
+ | Some({startBlock}) =>
1538
+ clientFilteredFrontiers->Array.push({
1539
+ blockNumber: Pervasives.max(startBlock - 1, progressBlockNumber),
1540
+ blockTimestamp: 0,
1256
1541
  })
1257
- nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1258
- chunkOffsetRef := chunkOffsetRef.contents + take
1259
- remainingRef := remainingRef.contents - take
1542
+ | None => ()
1260
1543
  }
1544
+ } else {
1545
+ let offsetRef = ref(0)
1546
+ let groupIdx = ref(0)
1547
+ while groupIdx.contents < groups->Array.length {
1548
+ let startBlock = (groups->Array.getUnsafe(groupIdx.contents)).startBlock
1549
+ // Addresses with different start blocks within range share a partition;
1550
+ // events before each address's effectiveStartBlock are dropped by the
1551
+ // source's address gate.
1552
+ let countRef = ref(0)
1553
+ let nextIdx = ref(groupIdx.contents)
1554
+ let joining = ref(true)
1555
+ while joining.contents && nextIdx.contents < groups->Array.length {
1556
+ let group = groups->Array.getUnsafe(nextIdx.contents)
1557
+ if group.startBlock - startBlock < OptimizedPartitions.tooFarBlockRange {
1558
+ countRef := countRef.contents + group.count
1559
+ nextIdx := nextIdx.contents + 1
1560
+ } else {
1561
+ joining := false
1562
+ }
1563
+ }
1261
1564
 
1262
- offsetRef := offsetRef.contents + countRef.contents
1263
- groupIdx := nextIdx.contents
1565
+ let latestFetchedBlock = {
1566
+ blockNumber: Pervasives.max(startBlock - 1, progressBlockNumber),
1567
+ blockTimestamp: 0,
1568
+ }
1569
+ let remainingRef = ref(countRef.contents)
1570
+ let chunkOffsetRef = ref(offsetRef.contents)
1571
+ while remainingRef.contents > 0 {
1572
+ let take = Pervasives.min(remainingRef.contents, maxAddrInPartition)
1573
+ let pAddresses =
1574
+ contractSet->AddressSet.slice(~offset=chunkOffsetRef.contents, ~limit=Some(take))
1575
+ partitions->Array.push({
1576
+ id: nextPartitionIndexRef.contents->Int.toString,
1577
+ latestFetchedBlock,
1578
+ selection: normalSelection,
1579
+ dynamicContract: isDynamic ? Some(contractName) : None,
1580
+ addresses: pAddresses,
1581
+ mergeBlock: None,
1582
+ mutPendingQueries: [],
1583
+ sourceRangeCapacity: 0,
1584
+ prevSourceRangeCapacity: 0,
1585
+ eventDensity: None,
1586
+ latestSourceRangeCapacityUpdateBlock: 0,
1587
+ })
1588
+ nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
1589
+ chunkOffsetRef := chunkOffsetRef.contents + take
1590
+ remainingRef := remainingRef.contents - take
1591
+ }
1592
+
1593
+ offsetRef := offsetRef.contents + countRef.contents
1594
+ groupIdx := nextIdx.contents
1595
+ }
1264
1596
  }
1265
1597
  }
1266
1598
 
@@ -1324,6 +1656,7 @@ OptimizedPartitions.t => {
1324
1656
  ~normalSelection,
1325
1657
  ~nextPartitionIndexRef,
1326
1658
  ~addressStore,
1659
+ ~clientFilteredFrontiers,
1327
1660
  ~knownHeight,
1328
1661
  )
1329
1662
  OptimizedPartitions.make(
@@ -1389,21 +1722,12 @@ let registerDynamicContracts = (
1389
1722
  // single batch conflict the same way as across batches.
1390
1723
  let verdicts = addressStore->AddressStore.registerBatch(registrations)
1391
1724
 
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
1725
  let registeringContractNames = []
1402
1726
  let hasNoEventsUpdatesRef = ref(false)
1403
1727
  for idx in 0 to verdicts->Array.length - 1 {
1404
1728
  let registration = registrations->Array.getUnsafe(idx)
1405
- let contractAddress = registration.address->Address.toString
1406
- let keep = switch verdicts->Array.getUnsafe(idx) {
1729
+ let verdict = verdicts->Array.getUnsafe(idx)
1730
+ let keep = switch verdict {
1407
1731
  | Added(_) =>
1408
1732
  if !(registeringContractNames->Array.includes(registration.contractName)) {
1409
1733
  registeringContractNames->Array.push(registration.contractName)->ignore
@@ -1414,42 +1738,18 @@ let registerDynamicContracts = (
1414
1738
  // Persist the address to the db so a future config change that adds
1415
1739
  // events for this contract can pick it up on restart, but skip partition
1416
1740
  // registration since there's nothing to fetch.
1417
- warn(
1418
- ~contractAddress,
1741
+ warnAddressRegistration(
1742
+ ~chainId=fetchState.chainId,
1743
+ ~contractAddress=registration.address,
1419
1744
  ~params={"contractName": registration.contractName},
1420
1745
  `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
1746
  )
1422
1747
  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
- )
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.`,
1748
+ | Conflict(_) | Duplicate(_) | Invalid =>
1749
+ verdict->warnRejectedRegistration(
1750
+ ~chainId=fetchState.chainId,
1751
+ ~contractAddress=registration.address,
1752
+ ~contractName=registration.contractName,
1453
1753
  )
1454
1754
  false
1455
1755
  }
@@ -1610,7 +1910,7 @@ let registerDynamicContracts = (
1610
1910
 
1611
1911
  /*
1612
1912
  Updates fetchState with a response for a given query.
1613
- Returns Error if the partition with given query cannot be found (unexpected)
1913
+ Throws if the partition with given query cannot be found (unexpected)
1614
1914
 
1615
1915
  newItems are ordered earliest to latest (as they are returned from the worker)
1616
1916
  */
@@ -1884,11 +2184,27 @@ let walkPartitionPending = (
1884
2184
  pqIdx := pqIdx.contents + 1
1885
2185
  }
1886
2186
 
2187
+ // Nothing in this partition's selection can match below its earliest start
2188
+ // block, so forward work skips straight to it instead of scanning up to it and
2189
+ // discarding whole pages. Only the cursor moves — `latestFetchedBlock` still
2190
+ // advances solely on a response, so no block is ever reported fetched that
2191
+ // wasn't. Bounded by the head and the query end block: past either, the
2192
+ // partition would offer no candidate at all, so it queries as before rather
2193
+ // than going quiet until the chain reaches its start block.
2194
+ let cursor = switch p.selection.startBlock {
2195
+ | Some(startBlock) if startBlock > cursor.contents =>
2196
+ switch Utils.Math.minOptInt(Some(headBlockNumber), queryEndBlock) {
2197
+ | Some(reachable) if startBlock <= reachable => startBlock
2198
+ | _ => cursor.contents
2199
+ }
2200
+ | _ => cursor.contents
2201
+ }
2202
+
1887
2203
  canContinue.contents
1888
2204
  ? Some({
1889
2205
  partitionId,
1890
2206
  p,
1891
- cursor: cursor.contents,
2207
+ cursor,
1892
2208
  chunksUsedThisCall: chunksUsedThisCall.contents,
1893
2209
  inFlightCount,
1894
2210
  queryEndBlock,
@@ -2335,7 +2651,7 @@ let make = (
2335
2651
  ~addressStore: AddressStore.t,
2336
2652
  ~addresses: array<Internal.indexingAddress>,
2337
2653
  ~maxAddrInPartition,
2338
- ~chainId,
2654
+ ~chainId: ChainId.t,
2339
2655
  ~maxOnBlockBufferSize,
2340
2656
  ~knownHeight,
2341
2657
  ~progressBlockNumber=startBlock - 1,
@@ -2343,6 +2659,7 @@ let make = (
2343
2659
  ~blockLag=0,
2344
2660
  ~firstEventBlock=None,
2345
2661
  ~clientFilterAddressThreshold=None,
2662
+ ~isResumed=false,
2346
2663
  ): t => {
2347
2664
  let latestFetchedBlock = {
2348
2665
  blockTimestamp: 0,
@@ -2368,10 +2685,10 @@ let make = (
2368
2685
  partitions->Array.push({
2369
2686
  id: partitions->Array.length->Int.toString,
2370
2687
  latestFetchedBlock,
2371
- selection: {
2372
- dependsOnAddresses: false,
2373
- onEventRegistrations: notDependingOnAddresses,
2374
- },
2688
+ selection: makeSelection(
2689
+ ~dependsOnAddresses=false,
2690
+ ~onEventRegistrations=notDependingOnAddresses,
2691
+ ),
2375
2692
  addresses: addressStore->AddressStore.emptySet,
2376
2693
  mergeBlock: None,
2377
2694
  dynamicContract: None,
@@ -2383,10 +2700,10 @@ let make = (
2383
2700
  })
2384
2701
  }
2385
2702
 
2386
- let normalSelection = {
2387
- dependsOnAddresses: true,
2388
- onEventRegistrations: normalRegistrations,
2389
- }
2703
+ let normalSelection = makeSelection(
2704
+ ~dependsOnAddresses=true,
2705
+ ~onEventRegistrations=normalRegistrations,
2706
+ )
2390
2707
 
2391
2708
  // Every address the chain indexes goes into the store — including ones whose
2392
2709
  // contract has no address-dependent events, so a later registration of the
@@ -2399,7 +2716,17 @@ let make = (
2399
2716
  registrationBlock: contract.registrationBlock,
2400
2717
  }),
2401
2718
  )
2402
- ->ignore
2719
+ // Verdicts are in the batch's order, so they line up with `addresses`. A
2720
+ // config address the store rejects is dropped exactly like a dynamic one, and
2721
+ // needs the same warning — restored dynamic addresses come through here too.
2722
+ ->Array.forEachWithIndex((verdict, idx) => {
2723
+ let contract = addresses->Array.getUnsafe(idx)
2724
+ verdict->warnRejectedRegistration(
2725
+ ~chainId,
2726
+ ~contractAddress=contract.address,
2727
+ ~contractName=contract.contractName,
2728
+ )
2729
+ })
2403
2730
 
2404
2731
  let dynamicContracts = Utils.Set.make()
2405
2732
  let clientFilteredContracts = Utils.Set.make()
@@ -2438,6 +2765,7 @@ let make = (
2438
2765
  ~chainId,
2439
2766
  ~addressCount,
2440
2767
  ~threshold,
2768
+ ~shouldLog=!isResumed,
2441
2769
  )
2442
2770
  }
2443
2771
  })
@@ -2462,7 +2790,7 @@ let make = (
2462
2790
  onBlockRegistrations->Utils.Array.isEmpty
2463
2791
  ) {
2464
2792
  JsError.throwWithMessage(
2465
- `Invalid configuration: Nothing to fetch on chain ${chainId->Int.toString}. ` ++
2793
+ `Invalid configuration: Nothing to fetch on chain ${chainId->ChainId.toString}. ` ++
2466
2794
  `addresses=${addresses->Array.length->Int.toString}, ` ++
2467
2795
  `onEventRegistrations=${onEventRegistrations->Array.length->Int.toString}, ` ++
2468
2796
  `normalRegistrations=${normalRegistrations
@@ -2586,6 +2914,14 @@ let rollback = (fetchState: t, ~addressStore: AddressStore.t, ~targetBlockNumber
2586
2914
  latestFetchedBlock: p.latestFetchedBlock.blockNumber > targetBlockNumber
2587
2915
  ? {blockNumber: targetBlockNumber, blockTimestamp: 0}
2588
2916
  : p.latestFetchedBlock,
2917
+ // Everything above the target is refetched by whichever partition this
2918
+ // one was catching up to, so there is nothing left to catch up on past
2919
+ // it. createPartitions drops the partition outright when the capped
2920
+ // block is already reached.
2921
+ mergeBlock: switch p.mergeBlock {
2922
+ | Some(mergeBlock) if mergeBlock > targetBlockNumber => Some(targetBlockNumber)
2923
+ | other => other
2924
+ },
2589
2925
  mutPendingQueries: rollbackPendingQueries(p.mutPendingQueries, ~targetBlockNumber),
2590
2926
  })
2591
2927
  ->ignore