envio 3.6.1 → 3.7.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 (80) hide show
  1. package/index.d.ts +197 -9
  2. package/package.json +6 -6
  3. package/src/Batch.res +64 -25
  4. package/src/Batch.res.mjs +69 -46
  5. package/src/ChainFetching.res +13 -17
  6. package/src/ChainFetching.res.mjs +9 -14
  7. package/src/ChainState.res +160 -119
  8. package/src/ChainState.res.mjs +75 -46
  9. package/src/ChainState.resi +15 -12
  10. package/src/Config.res +9 -2
  11. package/src/Config.res.mjs +4 -4
  12. package/src/Core.res +5 -0
  13. package/src/Ecosystem.res +0 -1
  14. package/src/Envio.res +16 -3
  15. package/src/EventConfigBuilder.res +155 -40
  16. package/src/EventConfigBuilder.res.mjs +76 -24
  17. package/src/EventProcessing.res +4 -8
  18. package/src/EventProcessing.res.mjs +4 -4
  19. package/src/EventUtils.res +3 -47
  20. package/src/FetchState.res +124 -104
  21. package/src/FetchState.res.mjs +116 -100
  22. package/src/HandlerRegister.res +108 -2
  23. package/src/HandlerRegister.res.mjs +59 -10
  24. package/src/HandlerRegister.resi +4 -0
  25. package/src/Internal.res +84 -24
  26. package/src/Internal.res.mjs +33 -2
  27. package/src/LazyLoader.res +12 -11
  28. package/src/LazyLoader.res.mjs +13 -7
  29. package/src/Main.res +22 -9
  30. package/src/Main.res.mjs +18 -5
  31. package/src/MemoryStorage.res +724 -0
  32. package/src/MemoryStorage.res.mjs +601 -0
  33. package/src/PgStorage.res +18 -7
  34. package/src/PgStorage.res.mjs +14 -11
  35. package/src/ReorgDetection.res +0 -222
  36. package/src/ReorgDetection.res.mjs +0 -163
  37. package/src/Rollback.res +14 -15
  38. package/src/Rollback.res.mjs +4 -5
  39. package/src/SimulateItems.res +2 -2
  40. package/src/Utils.res +3 -0
  41. package/src/bindings/ClickHouse.res +16 -11
  42. package/src/bindings/ClickHouse.res.mjs +10 -10
  43. package/src/bindings/Vitest.res +1 -1
  44. package/src/sources/BlockStore.res +115 -5
  45. package/src/sources/BlockStore.res.mjs +25 -0
  46. package/src/sources/Evm.res +0 -1
  47. package/src/sources/Evm.res.mjs +0 -1
  48. package/src/sources/EvmHyperSyncSource.res +19 -84
  49. package/src/sources/EvmHyperSyncSource.res.mjs +24 -60
  50. package/src/sources/Fuel.res +24 -4
  51. package/src/sources/Fuel.res.mjs +23 -3
  52. package/src/sources/FuelHyperSync.res +8 -3
  53. package/src/sources/FuelHyperSync.res.mjs +5 -4
  54. package/src/sources/FuelHyperSync.resi +3 -1
  55. package/src/sources/FuelHyperSyncClient.res +7 -10
  56. package/src/sources/FuelHyperSyncSource.res +18 -45
  57. package/src/sources/FuelHyperSyncSource.res.mjs +18 -35
  58. package/src/sources/HyperSync.res +49 -229
  59. package/src/sources/HyperSync.res.mjs +74 -191
  60. package/src/sources/HyperSync.resi +11 -35
  61. package/src/sources/HyperSyncClient.res +9 -79
  62. package/src/sources/HyperSyncClient.res.mjs +2 -6
  63. package/src/sources/RequestStat.res +5 -0
  64. package/src/sources/RequestStat.res.mjs +2 -0
  65. package/src/sources/RpcSource.res +149 -43
  66. package/src/sources/RpcSource.res.mjs +104 -41
  67. package/src/sources/SimulateSource.res +8 -5
  68. package/src/sources/SimulateSource.res.mjs +6 -6
  69. package/src/sources/Source.res +89 -16
  70. package/src/sources/Source.res.mjs +50 -1
  71. package/src/sources/SourceManager.res +255 -50
  72. package/src/sources/SourceManager.res.mjs +149 -55
  73. package/src/sources/SourceManager.resi +2 -6
  74. package/src/sources/Svm.res +0 -7
  75. package/src/sources/Svm.res.mjs +0 -8
  76. package/src/sources/SvmHyperSyncClient.res +21 -16
  77. package/src/sources/SvmHyperSyncClient.res.mjs +3 -4
  78. package/src/sources/SvmHyperSyncSource.res +25 -117
  79. package/src/sources/SvmHyperSyncSource.res.mjs +8 -121
  80. package/svm.schema.json +3 -2
@@ -199,7 +199,7 @@ let waitForRateLimitReset = async (sourceManager: t, ~resetMs, ~retry, ~logger)
199
199
  let waitMs = Pervasives.min(resetMs, 300_000)
200
200
  let log = retry >= 2 ? Logging.childWarn : Logging.childTrace
201
201
  logger->log({
202
- "msg": `HyperSync source is rate-limited not critical, the indexer will retry in ${(waitMs / 1000)
202
+ "msg": `HyperSync source is rate-limited - not critical, the indexer will retry in ${(waitMs / 1000)
203
203
  ->Int.toString}s. For higher limits upgrade your plan at https://envio.dev/app/api-tokens.`,
204
204
  "retry": retry,
205
205
  "waitMs": waitMs,
@@ -209,10 +209,45 @@ let waitForRateLimitReset = async (sourceManager: t, ~resetMs, ~retry, ~logger)
209
209
  sourceManager->stopRateLimitTimeout
210
210
  }
211
211
 
212
- let onReorg = (sourceManager: t, ~rollbackTargetBlock) => {
212
+ // A response is trusted only after its BlockStore has proved that it is
213
+ // internally coherent. `requiredBlockNumbers` is used by getBlockHashes to
214
+ // reject a response that simply omitted one of the requested hashes.
215
+ let validateResponseBlockStore = (
216
+ ~method: string,
217
+ ~blockStore: BlockStore.t,
218
+ ~requiredBlockNumbers: array<int>=[],
219
+ ) => {
220
+ switch blockStore->BlockStore.responseConflict->Null.toOption {
221
+ | Some({blockNumber, storedHash, receivedHash}) =>
222
+ throw(
223
+ Source.InconsistentResponse({
224
+ method,
225
+ blockNumber: Some(blockNumber),
226
+ storedHash: Some(storedHash),
227
+ receivedHash: Some(receivedHash),
228
+ missingBlockNumbers: [],
229
+ }),
230
+ )
231
+ | None =>
232
+ let missingBlockNumbers = blockStore->BlockStore.missingHashes(requiredBlockNumbers)
233
+ if missingBlockNumbers->Array.length > 0 {
234
+ throw(
235
+ Source.InconsistentResponse({
236
+ method,
237
+ blockNumber: None,
238
+ storedHash: None,
239
+ receivedHash: None,
240
+ missingBlockNumbers,
241
+ }),
242
+ )
243
+ }
244
+ }
245
+ }
246
+
247
+ let onReorg = (sourceManager: t) => {
213
248
  sourceManager.sourcesState->Array.forEach(({source}) => {
214
249
  switch source.onReorg {
215
- | Some(cb) => cb(~rollbackTargetBlock)
250
+ | Some(cb) => cb()
216
251
  | None => ()
217
252
  }
218
253
  })
@@ -558,7 +593,7 @@ let getNextSources = (sourceManager, ~isRealtime, ~excludedSources=?) => {
558
593
  } else if workingSecondarySources->Array.length > 0 {
559
594
  workingSecondarySources
560
595
  } else {
561
- // All primaries in recovery sort by oldest lastFailedAt (closest to recovery first)
596
+ // All primaries in recovery - sort by oldest lastFailedAt (closest to recovery first)
562
597
  allPrimarySources->Array.sort(compareByOldestFailure)
563
598
  allPrimarySources
564
599
  }
@@ -579,6 +614,145 @@ let getNextSource = (sourceManager, ~isRealtime, ~excludedSources=?) => {
579
614
  }
580
615
  }
581
616
 
617
+ let maxRetryBackoffMillis = 60_000
618
+
619
+ // One schedule for every retry of the same request, whatever made it fail:
620
+ // 100ms doubling up to the cap. `backoffBeforeRetry` still applies the caller's
621
+ // floor and the cap on top.
622
+ let retryBackoffMillis = retry =>
623
+ Pervasives.min(100. *. 2. ** retry->Int.toFloat, maxRetryBackoffMillis->Int.toFloat)->Float.toInt
624
+
625
+ // Floor for the retries driven by a condition the source reported itself
626
+ // (behind the head, inconsistent response). Unlike a caller-supplied backoff of
627
+ // 0, these must never busy-loop when there is no other source to move to.
628
+ let minRecoverableBackoffMillis = 50
629
+
630
+ // Back off before retrying the same request, failing the source over first when
631
+ // another one can actually take over. Marking `lastFailedAt` only demotes this
632
+ // source in the selection order - with no working alternative the next attempt
633
+ // lands right back here, so `minBackoffMillis` is what keeps a retry loop that
634
+ // never changes source from spinning at full speed.
635
+ let backoffBeforeRetry = async (
636
+ sourceManager: t,
637
+ sourceState: sourceState,
638
+ ~retry,
639
+ ~isRealtime,
640
+ ~backoffMillis,
641
+ ~minBackoffMillis=0,
642
+ ~excludedSources=?,
643
+ ) => {
644
+ // Give the source two attempts before demoting it, then re-try a failover
645
+ // every second attempt.
646
+ let switchedToWorkingSource = if retry >= 2 && retry->mod(2) === 0 {
647
+ let now = Date.now()
648
+ sourceState.lastFailedAt = Some(now)
649
+ switch sourceManager->getNextSource(~isRealtime, ~excludedSources?) {
650
+ | Some(next) =>
651
+ switch next.lastFailedAt {
652
+ | None => true
653
+ | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
654
+ }
655
+ | None => false
656
+ }
657
+ } else {
658
+ false
659
+ }
660
+ if switchedToWorkingSource {
661
+ // The next attempt goes to a different source, which is progress on its
662
+ // own - only pace it when the caller asked for a floor.
663
+ if minBackoffMillis > 0 {
664
+ await Utils.delay(minBackoffMillis)
665
+ }
666
+ } else {
667
+ await Utils.delay(
668
+ backoffMillis->Pervasives.max(minBackoffMillis)->Pervasives.min(maxRetryBackoffMillis),
669
+ )
670
+ }
671
+ }
672
+
673
+ // The queried block hasn't reached the backend instance that served the
674
+ // request. Expected around the head of a load-balanced backend, so early
675
+ // attempts stay quiet and short; a source that stays behind fails over like any
676
+ // other. Shared by every ecosystem's getItems and getBlockHashes.
677
+ let retryBehindHead = async (
678
+ sourceManager: t,
679
+ sourceState: sourceState,
680
+ ~retry,
681
+ ~isRealtime,
682
+ ~logger: Pino.t,
683
+ ~blockNumber: int,
684
+ ~method: string,
685
+ ~err: exn,
686
+ ~excludedSources=?,
687
+ ) => {
688
+ let backoffMillis = retry->retryBackoffMillis
689
+ let log = retry >= 4 ? Logging.childWarn : Logging.childTrace
690
+ logger->log({
691
+ "msg": `Block #${blockNumber->Int.toString} is not available on the ${sourceState.source.name} source yet. Instances of a load-balanced backend drift slightly around the head, so this is expected - indexing continues after an automatic retry.`,
692
+ "method": method,
693
+ "retry": retry,
694
+ "backOffMilliseconds": backoffMillis,
695
+ "err": err->Utils.prettifyExn,
696
+ })
697
+ await sourceManager->backoffBeforeRetry(
698
+ sourceState,
699
+ ~retry,
700
+ ~isRealtime,
701
+ ~backoffMillis,
702
+ ~minBackoffMillis=minRecoverableBackoffMillis,
703
+ ~excludedSources?,
704
+ )
705
+ }
706
+
707
+ // A source that keeps contradicting itself is not mid-reorg, it is broken, and
708
+ // no amount of retrying moves the chain forward. Roughly five minutes of the
709
+ // backoff schedule below.
710
+ let inconsistentResponseStallRetries = 13
711
+
712
+ // The response contradicted itself (the same block twice with different hashes,
713
+ // or a requested hash missing). It may be a reorg mid-request, so refetch before
714
+ // concluding anything about the chain.
715
+ let retryInconsistentResponse = async (
716
+ sourceManager: t,
717
+ sourceState: sourceState,
718
+ ~retry,
719
+ ~isRealtime,
720
+ ~logger: Pino.t,
721
+ ~method: string,
722
+ ~err: exn,
723
+ ~excludedSources=?,
724
+ ) => {
725
+ let backoffMillis = retry->retryBackoffMillis
726
+ let msg = `Received a partial indicator of a possible reorg from the ${sourceState.source.name} source while fetching ${method}. Retrying the request to better identify whether a reorg happened.`
727
+ let (log, msg) = if retry >= inconsistentResponseStallRetries {
728
+ (
729
+ Logging.childError,
730
+ msg ++ " It has disagreed with itself on every attempt for several minutes now, so this chain has stopped making progress - the endpoint is likely serving blocks and logs from nodes on different chains.",
731
+ )
732
+ } else {
733
+ (retry >= 2 ? Logging.childWarn : Logging.childTrace, msg)
734
+ }
735
+ logger->log({
736
+ "msg": msg,
737
+ "method": method,
738
+ "retry": retry,
739
+ "backOffMilliseconds": backoffMillis,
740
+ "err": err->Utils.prettifyExn,
741
+ })
742
+ // Before the backoff, not after: local state may point at an orphaned chain,
743
+ // and a sibling query on this source would keep reading it for as long as the
744
+ // wait lasts.
745
+ sourceState.source.onReorg->Option.forEach(cb => cb())
746
+ await sourceManager->backoffBeforeRetry(
747
+ sourceState,
748
+ ~retry,
749
+ ~isRealtime,
750
+ ~backoffMillis,
751
+ ~minBackoffMillis=minRecoverableBackoffMillis,
752
+ ~excludedSources?,
753
+ )
754
+ }
755
+
582
756
  // Polls for a block height greater than the given block number to ensure a new block is available for indexing.
583
757
  let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isRealtime, ~reducedPolling) => {
584
758
  let {sourcesState} = sourceManager
@@ -709,7 +883,7 @@ let executeQuery = async (
709
883
  ) => {
710
884
  let noSourcesError = "The indexer doesn't have data-sources which can continue fetching. Please, check the error logs or reach out to the Envio team."
711
885
 
712
- // Sources where the query is impossible lazily allocated, excluded for the duration of this query
886
+ // Sources where the query is impossible - lazily allocated, excluded for the duration of this query
713
887
  let excludedSourcesRef = ref(None)
714
888
 
715
889
  let toBlockRef = ref(query.toBlock)
@@ -768,6 +942,7 @@ let executeQuery = async (
768
942
  ~logger,
769
943
  )
770
944
  sourceState->recordRequestStats(response.requestStats)
945
+ validateResponseBlockStore(~method="getItems", ~blockStore=response.blockStore)
771
946
  sourceState.lastFailedAt = None
772
947
 
773
948
  // The response carries a fresh height for exactly this source, so during a
@@ -778,10 +953,37 @@ let executeQuery = async (
778
953
  }
779
954
  responseRef := Some(response)
780
955
  } catch {
781
- | Source.RateLimited({resetMs}) =>
956
+ | Source.RateLimited({resetMs, requestStats}) =>
957
+ sourceState->recordRequestStats(requestStats)
782
958
  await sourceManager->waitForRateLimitReset(~resetMs, ~retry, ~logger)
783
959
  retryRef := retryRef.contents + 1
784
960
 
961
+ | Source.SourceBehindHead({blockNumber, requestStats}) as err =>
962
+ sourceState->recordRequestStats(requestStats)
963
+ await sourceManager->retryBehindHead(
964
+ sourceState,
965
+ ~retry,
966
+ ~isRealtime,
967
+ ~logger,
968
+ ~blockNumber,
969
+ ~method="getItems",
970
+ ~err,
971
+ ~excludedSources=?excludedSourcesRef.contents,
972
+ )
973
+ retryRef := retryRef.contents + 1
974
+
975
+ | Source.InconsistentResponse(_) as err =>
976
+ await sourceManager->retryInconsistentResponse(
977
+ sourceState,
978
+ ~retry,
979
+ ~isRealtime,
980
+ ~logger,
981
+ ~method="getItems",
982
+ ~err,
983
+ ~excludedSources=?excludedSourcesRef.contents,
984
+ )
985
+ retryRef := retryRef.contents + 1
986
+
785
987
  | Source.GetItemsError(error) =>
786
988
  switch error {
787
989
  | UnsupportedSelection(_)
@@ -817,7 +1019,7 @@ let executeQuery = async (
817
1019
  toBlockRef := Some(toBlock)
818
1020
  retryRef := 0
819
1021
  | FailedGettingItems({exn, attemptedToBlock, retry: ImpossibleForTheQuery({message})}) =>
820
- // Don't set lastFailedAt the source isn't broken, the query just can't work on it
1022
+ // Don't set lastFailedAt - the source isn't broken, the query just can't work on it
821
1023
  let excludedSources = switch excludedSourcesRef.contents {
822
1024
  | Some(s) => s
823
1025
  | None =>
@@ -844,34 +1046,13 @@ let executeQuery = async (
844
1046
  "retry": retry,
845
1047
  "err": exn->Utils.prettifyExn,
846
1048
  })
847
-
848
- let shouldSwitch = switch retry {
849
- // Don't attempt a switch on first two failures
850
- | 0 | 1 => false
851
- // Then try to switch every second failure
852
- | _ => retry->mod(2) === 0
853
- }
854
-
855
- if shouldSwitch {
856
- let now = Date.now()
857
- sourceState.lastFailedAt = Some(now)
858
- // Check if there's a working (recovered) source to switch to immediately
859
- let nextSource =
860
- sourceManager->getNextSource(~isRealtime, ~excludedSources=?excludedSourcesRef.contents)
861
- let hasWorkingAlternative = switch nextSource {
862
- | Some(s) =>
863
- switch s.lastFailedAt {
864
- | None => true
865
- | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
866
- }
867
- | None => false
868
- }
869
- if !hasWorkingAlternative {
870
- await Utils.delay(Pervasives.min(backoffMillis, 60_000))
871
- }
872
- } else {
873
- await Utils.delay(Pervasives.min(backoffMillis, 60_000))
874
- }
1049
+ await sourceManager->backoffBeforeRetry(
1050
+ sourceState,
1051
+ ~retry,
1052
+ ~isRealtime,
1053
+ ~backoffMillis,
1054
+ ~excludedSources=?excludedSourcesRef.contents,
1055
+ )
875
1056
  retryRef := retryRef.contents + 1
876
1057
  }
877
1058
 
@@ -915,20 +1096,47 @@ let getBlockHashes = async (sourceManager: t, ~blockNumbers: array<int>, ~isReal
915
1096
  sourceState->recordRequestStats(res.requestStats)
916
1097
  switch res.result {
917
1098
  | Ok(data) =>
1099
+ validateResponseBlockStore(
1100
+ ~method="getBlockHashes",
1101
+ ~blockStore=data,
1102
+ ~requiredBlockNumbers=blockNumbers,
1103
+ )
918
1104
  sourceState.lastFailedAt = None
919
1105
  responseRef := Some(data)
920
1106
  | Error(exn) => throw(exn)
921
1107
  }
922
1108
  } catch {
923
- | Source.RateLimited({resetMs}) =>
1109
+ | Source.RateLimited({resetMs, requestStats}) =>
1110
+ sourceState->recordRequestStats(requestStats)
924
1111
  await sourceManager->waitForRateLimitReset(~resetMs, ~retry, ~logger)
925
1112
  retryRef := retryRef.contents + 1
926
1113
 
1114
+ | Source.SourceBehindHead({blockNumber, requestStats}) as err =>
1115
+ sourceState->recordRequestStats(requestStats)
1116
+ await sourceManager->retryBehindHead(
1117
+ sourceState,
1118
+ ~retry,
1119
+ ~isRealtime,
1120
+ ~logger,
1121
+ ~blockNumber,
1122
+ ~method="getBlockHashes",
1123
+ ~err,
1124
+ )
1125
+ retryRef := retryRef.contents + 1
1126
+
1127
+ | Source.InconsistentResponse(_) as err =>
1128
+ await sourceManager->retryInconsistentResponse(
1129
+ sourceState,
1130
+ ~retry,
1131
+ ~isRealtime,
1132
+ ~logger,
1133
+ ~method="getBlockHashes",
1134
+ ~err,
1135
+ )
1136
+ retryRef := retryRef.contents + 1
1137
+
927
1138
  | exn =>
928
- let backoffMillis = switch retry {
929
- | 0 => 500
930
- | _ => 1000 * retry
931
- }
1139
+ let backoffMillis = retry->retryBackoffMillis
932
1140
  let log = retry >= 4 ? Logging.childWarn : Logging.childTrace
933
1141
  logger->log({
934
1142
  "msg": "Failed to fetch block hashes. Retrying.",
@@ -936,16 +1144,13 @@ let getBlockHashes = async (sourceManager: t, ~blockNumbers: array<int>, ~isReal
936
1144
  "backOffMilliseconds": backoffMillis,
937
1145
  "err": exn->Utils.prettifyExn,
938
1146
  })
939
-
940
- let shouldSwitch = switch retry {
941
- | 0 | 1 => false
942
- | _ => retry->mod(2) === 0
943
- }
944
-
945
- if shouldSwitch {
946
- sourceState.lastFailedAt = Some(Date.now())
947
- }
948
- await Utils.delay(Pervasives.min(backoffMillis, 60_000))
1147
+ await sourceManager->backoffBeforeRetry(
1148
+ sourceState,
1149
+ ~retry,
1150
+ ~isRealtime,
1151
+ ~backoffMillis,
1152
+ ~minBackoffMillis=minRecoverableBackoffMillis,
1153
+ )
949
1154
  retryRef := retryRef.contents + 1
950
1155
  }
951
1156
  }