envio 3.0.2 → 3.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +0 -1
  2. package/evm.schema.json +15 -8
  3. package/fuel.schema.json +19 -12
  4. package/index.d.ts +0 -2
  5. package/package.json +6 -7
  6. package/rescript.json +1 -1
  7. package/src/Batch.res +4 -214
  8. package/src/Batch.res.mjs +6 -165
  9. package/src/ChainFetcher.res +12 -28
  10. package/src/ChainFetcher.res.mjs +8 -17
  11. package/src/ChainManager.res +10 -9
  12. package/src/ChainManager.res.mjs +6 -10
  13. package/src/Config.res +9 -25
  14. package/src/Config.res.mjs +17 -27
  15. package/src/Core.res +7 -0
  16. package/src/Ctx.res +1 -0
  17. package/src/Env.res +0 -8
  18. package/src/Env.res.mjs +0 -6
  19. package/src/EventConfigBuilder.res +13 -123
  20. package/src/EventConfigBuilder.res.mjs +6 -73
  21. package/src/EventProcessing.res +5 -29
  22. package/src/EventProcessing.res.mjs +11 -20
  23. package/src/EventUtils.res +0 -27
  24. package/src/EventUtils.res.mjs +0 -24
  25. package/src/FetchState.res +2 -15
  26. package/src/FetchState.res.mjs +3 -18
  27. package/src/GlobalState.res +26 -39
  28. package/src/GlobalState.res.mjs +12 -40
  29. package/src/HandlerLoader.res +6 -5
  30. package/src/HandlerLoader.res.mjs +27 -9
  31. package/src/HandlerRegister.res +1 -12
  32. package/src/HandlerRegister.res.mjs +1 -6
  33. package/src/HandlerRegister.resi +1 -1
  34. package/src/Hasura.res +96 -32
  35. package/src/Hasura.res.mjs +93 -38
  36. package/src/InMemoryStore.res +205 -45
  37. package/src/InMemoryStore.res.mjs +157 -40
  38. package/src/InMemoryTable.res +165 -249
  39. package/src/InMemoryTable.res.mjs +156 -227
  40. package/src/Internal.res +10 -34
  41. package/src/Internal.res.mjs +9 -3
  42. package/src/LoadLayer.res +5 -5
  43. package/src/LoadLayer.res.mjs +5 -5
  44. package/src/LogSelection.res +15 -19
  45. package/src/LogSelection.res.mjs +5 -6
  46. package/src/Main.res +4 -6
  47. package/src/Main.res.mjs +26 -15
  48. package/src/Persistence.res +7 -132
  49. package/src/Persistence.res.mjs +1 -102
  50. package/src/PgStorage.res +57 -40
  51. package/src/PgStorage.res.mjs +60 -34
  52. package/src/ReorgDetection.res +35 -58
  53. package/src/ReorgDetection.res.mjs +21 -29
  54. package/src/SimulateItems.res.mjs +21 -3
  55. package/src/Sink.res +2 -2
  56. package/src/Sink.res.mjs +1 -1
  57. package/src/TableIndices.res +9 -2
  58. package/src/TableIndices.res.mjs +7 -1
  59. package/src/TestIndexer.res +53 -60
  60. package/src/TestIndexer.res.mjs +77 -63
  61. package/src/TestIndexerProxyStorage.res +4 -14
  62. package/src/TestIndexerProxyStorage.res.mjs +1 -5
  63. package/src/UserContext.res +2 -4
  64. package/src/UserContext.res.mjs +4 -5
  65. package/src/Utils.res +0 -2
  66. package/src/Utils.res.mjs +0 -3
  67. package/src/bindings/ClickHouse.res +45 -38
  68. package/src/bindings/ClickHouse.res.mjs +16 -17
  69. package/src/bindings/Vitest.res +3 -0
  70. package/src/db/InternalTable.res +59 -18
  71. package/src/db/InternalTable.res.mjs +82 -51
  72. package/src/db/Table.res +9 -2
  73. package/src/db/Table.res.mjs +10 -7
  74. package/src/sources/EnvioApiClient.res +15 -0
  75. package/src/sources/EnvioApiClient.res.mjs +24 -0
  76. package/src/sources/EvmChain.res +32 -10
  77. package/src/sources/EvmChain.res.mjs +31 -5
  78. package/src/sources/HyperFuelSource.res +15 -58
  79. package/src/sources/HyperFuelSource.res.mjs +20 -39
  80. package/src/sources/HyperSync.res +54 -100
  81. package/src/sources/HyperSync.res.mjs +67 -96
  82. package/src/sources/HyperSync.resi +4 -22
  83. package/src/sources/HyperSyncClient.res +70 -247
  84. package/src/sources/HyperSyncClient.res.mjs +47 -46
  85. package/src/sources/HyperSyncSource.res +94 -166
  86. package/src/sources/HyperSyncSource.res.mjs +100 -127
  87. package/src/sources/RpcSource.res +43 -22
  88. package/src/sources/RpcSource.res.mjs +50 -35
  89. package/src/sources/SimulateSource.res +1 -7
  90. package/src/sources/SimulateSource.res.mjs +1 -7
  91. package/src/sources/Source.res +10 -1
  92. package/src/sources/Source.res.mjs +3 -0
  93. package/src/sources/SourceManager.res +177 -8
  94. package/src/sources/SourceManager.res.mjs +141 -3
  95. package/src/sources/SourceManager.resi +19 -0
  96. package/src/tui/Tui.res +44 -6
  97. package/src/tui/Tui.res.mjs +56 -8
  98. package/src/tui/components/TuiData.res +3 -0
  99. package/svm.schema.json +11 -4
  100. package/src/sources/HyperSyncJsonApi.res +0 -390
  101. package/src/sources/HyperSyncJsonApi.res.mjs +0 -237
@@ -39,13 +39,7 @@ let make = (~items: array<Internal.item>, ~endBlock: int, ~chain: ChainMap.Chain
39
39
  let reportedHeight = max(endBlock, 1)
40
40
  Promise.resolve({
41
41
  Source.knownHeight: reportedHeight,
42
- reorgGuard: {
43
- rangeLastBlock: {
44
- blockHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
45
- blockNumber: reportedHeight,
46
- },
47
- prevRangeLastBlock: None,
48
- },
42
+ blockHashes: [],
49
43
  parsedQueueItems: result,
50
44
  fromBlockQueried: 0,
51
45
  latestFetchedBlockNumber: reportedHeight,
@@ -22,13 +22,7 @@ function make(items, endBlock, chain) {
22
22
  let reportedHeight = Primitive_int.max(endBlock, 1);
23
23
  return Promise.resolve({
24
24
  knownHeight: reportedHeight,
25
- reorgGuard: {
26
- rangeLastBlock: {
27
- blockHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
28
- blockNumber: reportedHeight
29
- },
30
- prevRangeLastBlock: undefined
31
- },
25
+ blockHashes: [],
32
26
  parsedQueueItems: result,
33
27
  fromBlockQueried: 0,
34
28
  latestFetchedBlockNumber: reportedHeight,
@@ -12,7 +12,11 @@ Thes response returned from a block range fetch
12
12
  */
13
13
  type blockRangeFetchResponse = {
14
14
  knownHeight: int,
15
- reorgGuard: ReorgDetection.reorgGuard,
15
+ // Best-effort (blockNumber, blockHash) pairs observed while fetching this range.
16
+ // Used by reorg detection; gaps are OK, no extra requests are made to fill them.
17
+ // Duplicates with the same block number are allowed — registerReorgGuard treats
18
+ // a within-array hash mismatch on the same block number as a reorg.
19
+ blockHashes: array<ReorgDetection.blockData>,
16
20
  parsedQueueItems: array<Internal.item>,
17
21
  fromBlockQueried: int,
18
22
  latestFetchedBlockNumber: int,
@@ -25,6 +29,8 @@ type getItemsRetry =
25
29
  | WithBackoff({message: string, backoffMillis: int})
26
30
  | ImpossibleForTheQuery({message: string})
27
31
 
32
+ exception RateLimited({resetMs: int})
33
+
28
34
  type getItemsError =
29
35
  | UnsupportedSelection({message: string})
30
36
  | FailedGettingFieldSelection({exn: exn, blockNumber: int, logIndex: int, message: string})
@@ -58,4 +64,7 @@ type t = {
58
64
  ~logger: Pino.t,
59
65
  ) => promise<blockRangeFetchResponse>,
60
66
  createHeightSubscription?: (~onHeight: int => unit) => unit => unit,
67
+ // Invoked by SourceManager once a rollback target is known so the source can
68
+ // drop any state that may now point at an orphaned chain (e.g. RPC block cache).
69
+ onReorg?: (~rollbackTargetBlock: int) => unit,
61
70
  }
@@ -2,9 +2,12 @@
2
2
 
3
3
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
4
4
 
5
+ let RateLimited = /* @__PURE__ */Primitive_exceptions.create("Source.RateLimited");
6
+
5
7
  let GetItemsError = /* @__PURE__ */Primitive_exceptions.create("Source.GetItemsError");
6
8
 
7
9
  export {
10
+ RateLimited,
8
11
  GetItemsError,
9
12
  }
10
13
  /* No side effect */
@@ -32,10 +32,92 @@ type t = {
32
32
  mutable fetchingPartitionsCount: int,
33
33
  recoveryTimeout: float,
34
34
  mutable hasRealtime: bool,
35
+ mutable committedRateLimitTimeMs: float,
36
+ mutable rateLimitWaiters: int,
37
+ // Wall-clock timestamp (Date.now()) when the current rate-limit window
38
+ // started, or None if not currently waiting. Wall-clock so consumers
39
+ // (TUI) can compute elapsed time with their own Date.now() reads.
40
+ mutable activeRateLimitStartMs: option<float>,
41
+ // Wall-clock timestamp by which the server expects the longest current
42
+ // wait to clear. Tracks the latest reset across concurrent waiters so
43
+ // the displayed countdown reflects when the indexer will actually retry.
44
+ mutable activeRateLimitResetAtMs: option<float>,
35
45
  }
36
46
 
37
47
  let getActiveSource = sourceManager => sourceManager.activeSource
38
48
 
49
+ let getRateLimitTimeMs = sourceManager =>
50
+ sourceManager.committedRateLimitTimeMs +.
51
+ switch sourceManager.activeRateLimitStartMs {
52
+ | Some(startMs) => Date.now() -. startMs
53
+ | None => 0.0
54
+ }
55
+
56
+ let isRateLimited = sourceManager => sourceManager.activeRateLimitStartMs->Option.isSome
57
+
58
+ let getRateLimitResetInMs = sourceManager =>
59
+ switch sourceManager.activeRateLimitResetAtMs {
60
+ | Some(resetAt) =>
61
+ let remaining = resetAt -. Date.now()
62
+ remaining > 0.0 ? Some(remaining) : None
63
+ | None => None
64
+ }
65
+
66
+ let startRateLimitTimeout = (sourceManager, ~resetMs) => {
67
+ let now = Date.now()
68
+ if sourceManager.rateLimitWaiters === 0 {
69
+ sourceManager.activeRateLimitStartMs = Some(now)
70
+ }
71
+ let resetAt = now +. resetMs->Int.toFloat
72
+ sourceManager.activeRateLimitResetAtMs = switch sourceManager.activeRateLimitResetAtMs {
73
+ | Some(existing) => Some(Pervasives.max(existing, resetAt))
74
+ | None => Some(resetAt)
75
+ }
76
+ sourceManager.rateLimitWaiters = sourceManager.rateLimitWaiters + 1
77
+ }
78
+
79
+ let stopRateLimitTimeout = sourceManager => {
80
+ sourceManager.rateLimitWaiters = sourceManager.rateLimitWaiters - 1
81
+ if sourceManager.rateLimitWaiters === 0 {
82
+ switch sourceManager.activeRateLimitStartMs {
83
+ | Some(startMs) =>
84
+ sourceManager.committedRateLimitTimeMs =
85
+ sourceManager.committedRateLimitTimeMs +. Date.now() -. startMs
86
+ sourceManager.activeRateLimitStartMs = None
87
+ | None => ()
88
+ }
89
+ sourceManager.activeRateLimitResetAtMs = None
90
+ }
91
+ }
92
+
93
+ // Shared between executeQuery and getBlockHashes: wait out the server's
94
+ // suggested reset window. Cap at 5 minutes to protect against
95
+ // pathologically large server values. Escalates the log from trace to
96
+ // warn after the second consecutive retry so the indexer doesn't go
97
+ // silent under chronic throttling.
98
+ let waitForRateLimitReset = async (sourceManager: t, ~resetMs, ~retry, ~logger) => {
99
+ let waitMs = Pervasives.min(resetMs, 300_000)
100
+ let log = retry >= 2 ? Logging.childWarn : Logging.childTrace
101
+ logger->log({
102
+ "msg": `HyperSync source is rate-limited — not critical, the indexer will retry in ${(waitMs / 1000)
103
+ ->Int.toString}s. For higher limits upgrade your plan at https://envio.dev/app/api-tokens.`,
104
+ "retry": retry,
105
+ "waitMs": waitMs,
106
+ })
107
+ sourceManager->startRateLimitTimeout(~resetMs=waitMs)
108
+ await Utils.delay(waitMs)
109
+ sourceManager->stopRateLimitTimeout
110
+ }
111
+
112
+ let onReorg = (sourceManager: t, ~rollbackTargetBlock) => {
113
+ sourceManager.sourcesState->Array.forEach(({source}) => {
114
+ switch source.onReorg {
115
+ | Some(cb) => cb(~rollbackTargetBlock)
116
+ | None => ()
117
+ }
118
+ })
119
+ }
120
+
39
121
  type sourceRole = Primary | Secondary
40
122
 
41
123
  // Determines whether a source is Primary or Secondary given the current mode.
@@ -120,6 +202,10 @@ let make = (
120
202
  statusStart: Hrtime.makeTimer(),
121
203
  status: Idle,
122
204
  hasRealtime,
205
+ committedRateLimitTimeMs: 0.0,
206
+ rateLimitWaiters: 0,
207
+ activeRateLimitStartMs: None,
208
+ activeRateLimitResetAtMs: None,
123
209
  }
124
210
  }
125
211
 
@@ -242,14 +328,18 @@ let getSourceNewHeight = async (
242
328
  let retry = ref(0)
243
329
 
244
330
  while newHeight.contents <= knownHeight && status.contents !== Done {
245
- // If subscription exists, wait for next height event
246
331
  switch sourceState.unsubscribe {
247
332
  | Some(_) =>
248
333
  let subscriptionPromise = Promise.make((resolve, _reject) => {
249
334
  sourceState.pendingHeightResolvers->Array.push(resolve)
250
335
  })
251
- // If subscription goes quiet for half the stall timeout, fall back to REST polling
252
- let pollingFallback = Utils.delay(stallTimeout / 2)->Promise.then(async () => {
336
+ // If the subscription goes quiet for half the stall timeout, fall back to REST
337
+ // polling. Jitter the trigger across [stallTimeout/2, stallTimeout) so indexers
338
+ // that go quiet together don't all start polling at the same instant.
339
+ let half = stallTimeout / 2
340
+ let pollingFallback = Utils.delay(
341
+ half + (Math.random() *. half->Belt.Int.toFloat)->Belt.Float.toInt,
342
+ )->Promise.then(async () => {
253
343
  logger->Logging.childTrace({
254
344
  "msg": "onHeight subscription stale, switching to polling fallback",
255
345
  "source": source.name,
@@ -288,11 +378,15 @@ let getSourceNewHeight = async (
288
378
  switch source.createHeightSubscription {
289
379
  | Some(createSubscription) if isRealtime =>
290
380
  let unsubscribe = createSubscription(~onHeight=newHeight => {
291
- sourceState.knownHeight = newHeight
292
- // Resolve all pending height resolvers
293
- let resolvers = sourceState.pendingHeightResolvers
294
- sourceState.pendingHeightResolvers = []
295
- resolvers->Array.forEach(resolve => resolve(newHeight))
381
+ // Ignore non-increasing heights. The height stream re-emits the current
382
+ // head on every (re)connect; waking the wait loop on a height we already
383
+ // know spins it and leaks fallback pollers (#1270).
384
+ if newHeight > sourceState.knownHeight {
385
+ sourceState.knownHeight = newHeight
386
+ let resolvers = sourceState.pendingHeightResolvers
387
+ sourceState.pendingHeightResolvers = []
388
+ resolvers->Array.forEach(resolve => resolve(newHeight))
389
+ }
296
390
  })
297
391
  sourceState.unsubscribe = Some(unsubscribe)
298
392
  | _ =>
@@ -599,6 +693,10 @@ let executeQuery = async (
599
693
  sourceState.lastFailedAt = None
600
694
  responseRef := Some(response)
601
695
  } catch {
696
+ | Source.RateLimited({resetMs}) =>
697
+ await sourceManager->waitForRateLimitReset(~resetMs, ~retry, ~logger)
698
+ retryRef := retryRef.contents + 1
699
+
602
700
  | Source.GetItemsError(error) =>
603
701
  switch error {
604
702
  | UnsupportedSelection(_)
@@ -699,3 +797,74 @@ let executeQuery = async (
699
797
 
700
798
  responseRef.contents->Option.getUnsafe
701
799
  }
800
+
801
+ let getBlockHashes = async (sourceManager: t, ~blockNumbers: array<int>, ~isRealtime: bool) => {
802
+ let responseRef = ref(None)
803
+ let retryRef = ref(0)
804
+
805
+ while responseRef.contents->Option.isNone {
806
+ let sourceState = switch sourceManager->getNextSource(~isRealtime) {
807
+ | Some(s) => s
808
+ | None =>
809
+ let logger = Logging.createChild(
810
+ ~params={"chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId},
811
+ )
812
+ %raw(`null`)->ErrorHandling.mkLogAndRaise(
813
+ ~logger,
814
+ ~msg="No data-sources available for fetching block hashes.",
815
+ )
816
+ }
817
+ sourceManager.activeSource = sourceState.source
818
+ let source = sourceState.source
819
+ let retry = retryRef.contents
820
+
821
+ let logger = Logging.createChild(
822
+ ~params={
823
+ "chainId": source.chain->ChainMap.Chain.toChainId,
824
+ "logType": "Block Hash Query",
825
+ "source": source.name,
826
+ "retry": retry,
827
+ },
828
+ )
829
+
830
+ try {
831
+ let res = await source.getBlockHashes(~blockNumbers, ~logger)
832
+ switch res {
833
+ | Ok(data) =>
834
+ sourceState.lastFailedAt = None
835
+ responseRef := Some(data)
836
+ | Error(exn) => throw(exn)
837
+ }
838
+ } catch {
839
+ | Source.RateLimited({resetMs}) =>
840
+ await sourceManager->waitForRateLimitReset(~resetMs, ~retry, ~logger)
841
+ retryRef := retryRef.contents + 1
842
+
843
+ | exn =>
844
+ let backoffMillis = switch retry {
845
+ | 0 => 500
846
+ | _ => 1000 * retry
847
+ }
848
+ let log = retry >= 4 ? Logging.childWarn : Logging.childTrace
849
+ logger->log({
850
+ "msg": "Failed to fetch block hashes. Retrying.",
851
+ "retry": retry,
852
+ "backOffMilliseconds": backoffMillis,
853
+ "err": exn->Utils.prettifyExn,
854
+ })
855
+
856
+ let shouldSwitch = switch retry {
857
+ | 0 | 1 => false
858
+ | _ => retry->mod(2) === 0
859
+ }
860
+
861
+ if shouldSwitch {
862
+ sourceState.lastFailedAt = Some(Date.now())
863
+ }
864
+ await Utils.delay(Pervasives.min(backoffMillis, 60_000))
865
+ retryRef := retryRef.contents + 1
866
+ }
867
+ }
868
+
869
+ responseRef.contents->Option.getUnsafe
870
+ }
@@ -10,6 +10,7 @@ import * as ErrorHandling from "../ErrorHandling.res.mjs";
10
10
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
11
11
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
12
12
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
13
+ import * as Primitive_float from "@rescript/runtime/lib/es6/Primitive_float.js";
13
14
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
14
15
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
15
16
 
@@ -17,6 +18,70 @@ function getActiveSource(sourceManager) {
17
18
  return sourceManager.activeSource;
18
19
  }
19
20
 
21
+ function getRateLimitTimeMs(sourceManager) {
22
+ let startMs = sourceManager.activeRateLimitStartMs;
23
+ return sourceManager.committedRateLimitTimeMs + (
24
+ startMs !== undefined ? Date.now() - startMs : 0.0
25
+ );
26
+ }
27
+
28
+ function isRateLimited(sourceManager) {
29
+ return Stdlib_Option.isSome(sourceManager.activeRateLimitStartMs);
30
+ }
31
+
32
+ function getRateLimitResetInMs(sourceManager) {
33
+ let resetAt = sourceManager.activeRateLimitResetAtMs;
34
+ if (resetAt === undefined) {
35
+ return;
36
+ }
37
+ let remaining = resetAt - Date.now();
38
+ if (remaining > 0.0) {
39
+ return remaining;
40
+ }
41
+ }
42
+
43
+ function startRateLimitTimeout(sourceManager, resetMs) {
44
+ let now = Date.now();
45
+ if (sourceManager.rateLimitWaiters === 0) {
46
+ sourceManager.activeRateLimitStartMs = now;
47
+ }
48
+ let resetAt = now + resetMs;
49
+ let existing = sourceManager.activeRateLimitResetAtMs;
50
+ sourceManager.activeRateLimitResetAtMs = existing !== undefined ? Primitive_float.max(existing, resetAt) : resetAt;
51
+ sourceManager.rateLimitWaiters = sourceManager.rateLimitWaiters + 1 | 0;
52
+ }
53
+
54
+ async function waitForRateLimitReset(sourceManager, resetMs, retry, logger) {
55
+ let waitMs = Primitive_int.min(resetMs, 300000);
56
+ let log = retry >= 2 ? Logging.childWarn : Logging.childTrace;
57
+ log(logger, {
58
+ msg: `HyperSync source is rate-limited — not critical, the indexer will retry in ` + (waitMs / 1000 | 0).toString() + `s. For higher limits upgrade your plan at https://envio.dev/app/api-tokens.`,
59
+ retry: retry,
60
+ waitMs: waitMs
61
+ });
62
+ startRateLimitTimeout(sourceManager, waitMs);
63
+ await Utils.delay(waitMs);
64
+ sourceManager.rateLimitWaiters = sourceManager.rateLimitWaiters - 1 | 0;
65
+ if (sourceManager.rateLimitWaiters !== 0) {
66
+ return;
67
+ }
68
+ let startMs = sourceManager.activeRateLimitStartMs;
69
+ if (startMs !== undefined) {
70
+ sourceManager.committedRateLimitTimeMs = sourceManager.committedRateLimitTimeMs + Date.now() - startMs;
71
+ sourceManager.activeRateLimitStartMs = undefined;
72
+ }
73
+ sourceManager.activeRateLimitResetAtMs = undefined;
74
+ }
75
+
76
+ function onReorg(sourceManager, rollbackTargetBlock) {
77
+ sourceManager.sourcesState.forEach(param => {
78
+ let cb = param.source.onReorg;
79
+ if (cb !== undefined) {
80
+ return cb(rollbackTargetBlock);
81
+ }
82
+ });
83
+ }
84
+
20
85
  function getSourceRole(sourceFor, isRealtime, hasRealtime) {
21
86
  if (isRealtime) {
22
87
  switch (sourceFor) {
@@ -83,7 +148,11 @@ function make(sources, maxPartitionConcurrency, isRealtime, newBlockStallTimeout
83
148
  waitingForNewBlockStateId: undefined,
84
149
  fetchingPartitionsCount: 0,
85
150
  recoveryTimeout: recoveryTimeout,
86
- hasRealtime: hasRealtime
151
+ hasRealtime: hasRealtime,
152
+ committedRateLimitTimeMs: 0.0,
153
+ rateLimitWaiters: 0,
154
+ activeRateLimitStartMs: undefined,
155
+ activeRateLimitResetAtMs: undefined
87
156
  };
88
157
  }
89
158
 
@@ -186,7 +255,8 @@ async function getSourceNewHeight(sourceManager, sourceState, knownHeight, stall
186
255
  let subscriptionPromise = new Promise((resolve, _reject) => {
187
256
  sourceState.pendingHeightResolvers.push(resolve);
188
257
  });
189
- let pollingFallback = Utils.delay(stallTimeout / 2 | 0).then(async () => {
258
+ let half = stallTimeout / 2 | 0;
259
+ let pollingFallback = Utils.delay(half + (Math.random() * half | 0) | 0).then(async () => {
190
260
  Logging.childTrace(logger, {
191
261
  msg: "onHeight subscription stale, switching to polling fallback",
192
262
  source: source.name,
@@ -222,6 +292,9 @@ async function getSourceNewHeight(sourceManager, sourceState, knownHeight, stall
222
292
  let exit = 0;
223
293
  if (createSubscription !== undefined && isRealtime) {
224
294
  let unsubscribe = createSubscription(newHeight => {
295
+ if (newHeight <= sourceState.knownHeight) {
296
+ return;
297
+ }
225
298
  sourceState.knownHeight = newHeight;
226
299
  let resolvers = sourceState.pendingHeightResolvers;
227
300
  sourceState.pendingHeightResolvers = [];
@@ -440,7 +513,10 @@ async function executeQuery(sourceManager, query, knownHeight, isRealtime) {
440
513
  responseRef = response;
441
514
  } catch (raw_error) {
442
515
  let error = Primitive_exceptions.internalToException(raw_error);
443
- if (error.RE_EXN_ID === Source.GetItemsError) {
516
+ if (error.RE_EXN_ID === Source.RateLimited) {
517
+ await waitForRateLimitReset(sourceManager, error.resetMs, retry, logger$2);
518
+ retryRef = retryRef + 1 | 0;
519
+ } else if (error.RE_EXN_ID === Source.GetItemsError) {
444
520
  let error$1 = error._1;
445
521
  let exit = 0;
446
522
  switch (error$1.TAG) {
@@ -543,13 +619,75 @@ async function executeQuery(sourceManager, query, knownHeight, isRealtime) {
543
619
  return responseRef;
544
620
  }
545
621
 
622
+ async function getBlockHashes(sourceManager, blockNumbers, isRealtime) {
623
+ let responseRef;
624
+ let retryRef = 0;
625
+ while (Stdlib_Option.isNone(responseRef)) {
626
+ let s = getNextSource(sourceManager, isRealtime, undefined);
627
+ let sourceState;
628
+ if (s !== undefined) {
629
+ sourceState = s;
630
+ } else {
631
+ let logger = Logging.createChild({
632
+ chainId: sourceManager.activeSource.chain
633
+ });
634
+ sourceState = ErrorHandling.mkLogAndRaise(logger, "No data-sources available for fetching block hashes.", null);
635
+ }
636
+ sourceManager.activeSource = sourceState.source;
637
+ let source = sourceState.source;
638
+ let retry = retryRef;
639
+ let logger$1 = Logging.createChild({
640
+ chainId: source.chain,
641
+ logType: "Block Hash Query",
642
+ source: source.name,
643
+ retry: retry
644
+ });
645
+ try {
646
+ let res = await source.getBlockHashes(blockNumbers, logger$1);
647
+ if (res.TAG === "Ok") {
648
+ sourceState.lastFailedAt = undefined;
649
+ responseRef = res._0;
650
+ } else {
651
+ throw res._0;
652
+ }
653
+ } catch (raw_exn) {
654
+ let exn = Primitive_exceptions.internalToException(raw_exn);
655
+ if (exn.RE_EXN_ID === Source.RateLimited) {
656
+ await waitForRateLimitReset(sourceManager, exn.resetMs, retry, logger$1);
657
+ retryRef = retryRef + 1 | 0;
658
+ } else {
659
+ let backoffMillis = retry !== 0 ? 1000 * retry | 0 : 500;
660
+ let log = retry >= 4 ? Logging.childWarn : Logging.childTrace;
661
+ log(logger$1, {
662
+ msg: "Failed to fetch block hashes. Retrying.",
663
+ retry: retry,
664
+ backOffMilliseconds: backoffMillis,
665
+ err: Utils.prettifyExn(exn)
666
+ });
667
+ let shouldSwitch = retry !== 0 && retry !== 1 ? retry % 2 === 0 : false;
668
+ if (shouldSwitch) {
669
+ sourceState.lastFailedAt = Date.now();
670
+ }
671
+ await Utils.delay(Primitive_int.min(backoffMillis, 60000));
672
+ retryRef = retryRef + 1 | 0;
673
+ }
674
+ }
675
+ };
676
+ return responseRef;
677
+ }
678
+
546
679
  export {
547
680
  getSourceRole,
548
681
  make,
549
682
  getActiveSource,
683
+ onReorg,
550
684
  fetchNext,
551
685
  waitForNewBlock,
552
686
  executeQuery,
553
687
  makeGetHeightRetryInterval,
688
+ getBlockHashes,
689
+ getRateLimitTimeMs,
690
+ isRateLimited,
691
+ getRateLimitResetInMs,
554
692
  }
555
693
  /* Utils Not a pure module */
@@ -22,6 +22,8 @@ let make: (
22
22
 
23
23
  let getActiveSource: t => Source.t
24
24
 
25
+ let onReorg: (t, ~rollbackTargetBlock: int) => unit
26
+
25
27
  let fetchNext: (
26
28
  t,
27
29
  ~fetchState: FetchState.t,
@@ -50,3 +52,20 @@ let makeGetHeightRetryInterval: (
50
52
  ~backoffMultiplicative: int,
51
53
  ~maxRetryInterval: int,
52
54
  ) => (~retry: int) => int
55
+
56
+ let getBlockHashes: (
57
+ t,
58
+ ~blockNumbers: array<int>,
59
+ ~isRealtime: bool,
60
+ ) => promise<array<ReorgDetection.blockDataWithTimestamp>>
61
+
62
+ // Total time the indexer spent waiting on rate limits, including any
63
+ // currently-in-progress window measured against Date.now() at call time.
64
+ let getRateLimitTimeMs: t => float
65
+
66
+ // True if at least one request is currently waiting on a rate-limit reset.
67
+ let isRateLimited: t => bool
68
+
69
+ // Milliseconds remaining until the longest active rate-limit window resets,
70
+ // or None if not currently waiting (or the window has already elapsed).
71
+ let getRateLimitResetInMs: t => option<float>
package/src/tui/Tui.res CHANGED
@@ -92,7 +92,7 @@ module EventsPerSecond = {
92
92
  }
93
93
  }
94
94
 
95
- let use = (~totalEventsProcessed: float) => {
95
+ let use = (~totalEventsProcessed: float, ~tick: int) => {
96
96
  let (samples, setSamples) = React.useState((): array<sample> => [])
97
97
 
98
98
  React.useEffect1(() => {
@@ -103,7 +103,7 @@ module EventsPerSecond = {
103
103
  kept->Array.concat([{time: now, events: totalEventsProcessed}])
104
104
  })
105
105
  None
106
- }, [totalEventsProcessed])
106
+ }, [tick])
107
107
 
108
108
  computeEps(samples)
109
109
  }
@@ -132,12 +132,15 @@ module App = {
132
132
  @react.component
133
133
  let make = (~getState) => {
134
134
  let stdoutColumns = Hooks.useStdoutColumns()
135
- let (state: GlobalState.t, setState) = React.useState(() => getState())
135
+ // GlobalState is mutated in place — passing the same ref to useState
136
+ // would bail out via Object.is and skip the re-render. Tick a counter
137
+ // instead and read state freshly from getState() on every render.
138
+ let (tick, setTick) = React.useState(() => 0)
139
+ let state: GlobalState.t = getState()
136
140
 
137
- // useEffect to refresh state every 500ms
138
141
  React.useEffect(() => {
139
142
  let intervalId = setInterval(() => {
140
- setState(_ => getState())
143
+ setTick(t => t + 1)
141
144
  }, 500)
142
145
 
143
146
  Some(
@@ -210,6 +213,9 @@ module App = {
210
213
  poweredByHyperSync: (
211
214
  cf.sourceManager->SourceManager.getActiveSource
212
215
  ).poweredByHyperSync,
216
+ rateLimitTimeMs: cf.sourceManager->SourceManager.getRateLimitTimeMs,
217
+ isRateLimited: cf.sourceManager->SourceManager.isRateLimited,
218
+ rateLimitResetInMs: cf.sourceManager->SourceManager.getRateLimitResetInMs,
213
219
  }: TuiData.chain
214
220
  )
215
221
  })
@@ -225,7 +231,7 @@ module App = {
225
231
  acc
226
232
  }
227
233
  })
228
- let eventsPerSecond = EventsPerSecond.use(~totalEventsProcessed)
234
+ let eventsPerSecond = EventsPerSecond.use(~totalEventsProcessed, ~tick)
229
235
 
230
236
  <Box flexDirection={Column}>
231
237
  <BigText
@@ -257,6 +263,38 @@ module App = {
257
263
  eventsPerSecond={SyncETA.isIndexerFullySynced(chains) ? None : eventsPerSecond}
258
264
  />
259
265
  <SyncETA chains indexerStartTime=state.indexerStartTime />
266
+ {
267
+ let maxRateLimitTimeMs =
268
+ chains->Array.reduce(0., (acc, chain) => Pervasives.max(acc, chain.rateLimitTimeMs))
269
+ let maxResetInMs =
270
+ chains->Array.reduce(0.0, (acc, chain) =>
271
+ Pervasives.max(acc, chain.rateLimitResetInMs->Option.getOr(0.0))
272
+ )
273
+ maxRateLimitTimeMs > 1000.
274
+ ? {
275
+ let rateLimitSecs = Math.round(maxRateLimitTimeMs /. 1000.)
276
+ let activeSuffix = if maxResetInMs > 0.0 {
277
+ let resetSecs = Pervasives.max(1.0, Math.ceil(maxResetInMs /. 1000.))
278
+ ` (⏳ ${resetSecs->TuiData.formatFloatLocaleString}s until reset)`
279
+ } else {
280
+ ""
281
+ }
282
+ <Box flexDirection={Column}>
283
+ <Newline />
284
+ <Text color={Danger}>
285
+ {`Backfill ${rateLimitSecs->TuiData.formatFloatLocaleString}s slower due to your plan's rate limit${activeSuffix}`->React.string}
286
+ </Text>
287
+ <Text color={Danger}>
288
+ <Text color={Danger}> {"Upgrade at "->React.string} </Text>
289
+ <Text color={Danger} underline=true>
290
+ {"https://envio.dev/app/api-tokens"->React.string}
291
+ </Text>
292
+ <Text color={Danger}> {" for higher rate limits."->React.string} </Text>
293
+ </Text>
294
+ </Box>
295
+ }
296
+ : React.null
297
+ }
260
298
  <Newline />
261
299
  <Box flexDirection={Row}>
262
300
  <Text> {"GraphQL: "->React.string} </Text>