envio 3.0.0-alpha.2 → 3.0.0-alpha.21

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 (184) hide show
  1. package/README.md +164 -30
  2. package/bin.mjs +49 -0
  3. package/evm.schema.json +79 -169
  4. package/fuel.schema.json +50 -21
  5. package/index.d.ts +578 -1
  6. package/index.js +4 -0
  7. package/package.json +47 -31
  8. package/rescript.json +4 -1
  9. package/src/Batch.res +11 -8
  10. package/src/Batch.res.mjs +11 -9
  11. package/src/ChainFetcher.res +531 -0
  12. package/src/ChainFetcher.res.mjs +339 -0
  13. package/src/ChainManager.res +190 -0
  14. package/src/ChainManager.res.mjs +166 -0
  15. package/src/Change.res +3 -3
  16. package/src/Config.gen.ts +19 -0
  17. package/src/Config.res +725 -25
  18. package/src/Config.res.mjs +692 -26
  19. package/src/{Indexer.res → Ctx.res} +1 -1
  20. package/src/Ecosystem.res +9 -124
  21. package/src/Ecosystem.res.mjs +19 -160
  22. package/src/Env.res +33 -73
  23. package/src/Env.res.mjs +29 -85
  24. package/src/Envio.gen.ts +3 -1
  25. package/src/Envio.res +77 -9
  26. package/src/Envio.res.mjs +39 -1
  27. package/src/EventConfigBuilder.res +408 -0
  28. package/src/EventConfigBuilder.res.mjs +376 -0
  29. package/src/EventProcessing.res +469 -0
  30. package/src/EventProcessing.res.mjs +337 -0
  31. package/src/EvmTypes.gen.ts +6 -0
  32. package/src/EvmTypes.res +1 -0
  33. package/src/FetchState.res +1256 -639
  34. package/src/FetchState.res.mjs +1135 -612
  35. package/src/GlobalState.res +1224 -0
  36. package/src/GlobalState.res.mjs +1291 -0
  37. package/src/GlobalStateManager.res +68 -0
  38. package/src/GlobalStateManager.res.mjs +75 -0
  39. package/src/GlobalStateManager.resi +7 -0
  40. package/src/HandlerLoader.res +89 -0
  41. package/src/HandlerLoader.res.mjs +79 -0
  42. package/src/HandlerRegister.res +357 -0
  43. package/src/HandlerRegister.res.mjs +299 -0
  44. package/src/HandlerRegister.resi +30 -0
  45. package/src/Hasura.res +111 -175
  46. package/src/Hasura.res.mjs +88 -150
  47. package/src/InMemoryStore.res +1 -1
  48. package/src/InMemoryStore.res.mjs +3 -3
  49. package/src/InMemoryTable.res +1 -1
  50. package/src/InMemoryTable.res.mjs +1 -1
  51. package/src/Internal.gen.ts +6 -0
  52. package/src/Internal.res +265 -12
  53. package/src/Internal.res.mjs +115 -1
  54. package/src/LoadLayer.res +444 -0
  55. package/src/LoadLayer.res.mjs +296 -0
  56. package/src/LoadLayer.resi +32 -0
  57. package/src/LogSelection.res +33 -27
  58. package/src/LogSelection.res.mjs +6 -0
  59. package/src/Logging.res +21 -7
  60. package/src/Logging.res.mjs +16 -8
  61. package/src/Main.res +390 -0
  62. package/src/Main.res.mjs +341 -0
  63. package/src/Persistence.res +7 -21
  64. package/src/Persistence.res.mjs +3 -3
  65. package/src/PgStorage.gen.ts +10 -0
  66. package/src/PgStorage.res +116 -69
  67. package/src/PgStorage.res.d.mts +5 -0
  68. package/src/PgStorage.res.mjs +93 -50
  69. package/src/Prometheus.res +294 -224
  70. package/src/Prometheus.res.mjs +353 -340
  71. package/src/ReorgDetection.res +6 -10
  72. package/src/ReorgDetection.res.mjs +6 -6
  73. package/src/SafeCheckpointTracking.res +4 -4
  74. package/src/SafeCheckpointTracking.res.mjs +2 -2
  75. package/src/SimulateItems.res +353 -0
  76. package/src/SimulateItems.res.mjs +335 -0
  77. package/src/Sink.res +4 -2
  78. package/src/Sink.res.mjs +2 -1
  79. package/src/TableIndices.res +0 -1
  80. package/src/TestIndexer.res +913 -0
  81. package/src/TestIndexer.res.mjs +698 -0
  82. package/src/TestIndexerProxyStorage.res +205 -0
  83. package/src/TestIndexerProxyStorage.res.mjs +151 -0
  84. package/src/TopicFilter.res +1 -1
  85. package/src/Types.ts +1 -1
  86. package/src/UserContext.res +424 -0
  87. package/src/UserContext.res.mjs +279 -0
  88. package/src/Utils.res +97 -26
  89. package/src/Utils.res.mjs +91 -44
  90. package/src/bindings/BigInt.res +10 -0
  91. package/src/bindings/BigInt.res.mjs +15 -0
  92. package/src/bindings/ClickHouse.res +120 -23
  93. package/src/bindings/ClickHouse.res.mjs +118 -28
  94. package/src/bindings/DateFns.res +74 -0
  95. package/src/bindings/DateFns.res.mjs +22 -0
  96. package/src/bindings/EventSource.res +11 -2
  97. package/src/bindings/EventSource.res.mjs +8 -1
  98. package/src/bindings/Express.res +1 -0
  99. package/src/bindings/Hrtime.res +14 -1
  100. package/src/bindings/Hrtime.res.mjs +22 -2
  101. package/src/bindings/Hrtime.resi +4 -0
  102. package/src/bindings/Lodash.res +0 -1
  103. package/src/bindings/NodeJs.res +49 -3
  104. package/src/bindings/NodeJs.res.mjs +11 -3
  105. package/src/bindings/Pino.res +24 -10
  106. package/src/bindings/Pino.res.mjs +14 -8
  107. package/src/bindings/Postgres.gen.ts +8 -0
  108. package/src/bindings/Postgres.res +5 -1
  109. package/src/bindings/Postgres.res.d.mts +5 -0
  110. package/src/bindings/PromClient.res +0 -10
  111. package/src/bindings/PromClient.res.mjs +0 -3
  112. package/src/bindings/Vitest.res +144 -0
  113. package/src/bindings/Vitest.res.mjs +9 -0
  114. package/src/bindings/WebSocket.res +27 -0
  115. package/src/bindings/WebSocket.res.mjs +2 -0
  116. package/src/bindings/Yargs.res +8 -0
  117. package/src/bindings/Yargs.res.mjs +2 -0
  118. package/src/db/EntityHistory.res +7 -7
  119. package/src/db/EntityHistory.res.mjs +9 -9
  120. package/src/db/InternalTable.res +59 -111
  121. package/src/db/InternalTable.res.mjs +73 -104
  122. package/src/db/Table.res +27 -8
  123. package/src/db/Table.res.mjs +25 -14
  124. package/src/sources/Evm.res +84 -0
  125. package/src/sources/Evm.res.mjs +105 -0
  126. package/src/sources/EvmChain.res +94 -0
  127. package/src/sources/EvmChain.res.mjs +60 -0
  128. package/src/sources/Fuel.res +19 -34
  129. package/src/sources/Fuel.res.mjs +34 -16
  130. package/src/sources/FuelSDK.res +38 -0
  131. package/src/sources/FuelSDK.res.mjs +29 -0
  132. package/src/sources/HyperFuel.res +2 -2
  133. package/src/sources/HyperFuel.resi +1 -1
  134. package/src/sources/HyperFuelClient.res +2 -2
  135. package/src/sources/HyperFuelSource.res +35 -13
  136. package/src/sources/HyperFuelSource.res.mjs +26 -16
  137. package/src/sources/HyperSync.res +61 -60
  138. package/src/sources/HyperSync.res.mjs +53 -67
  139. package/src/sources/HyperSync.resi +6 -4
  140. package/src/sources/HyperSyncClient.res +29 -2
  141. package/src/sources/HyperSyncClient.res.mjs +9 -0
  142. package/src/sources/HyperSyncHeightStream.res +76 -118
  143. package/src/sources/HyperSyncHeightStream.res.mjs +68 -75
  144. package/src/sources/HyperSyncSource.res +122 -143
  145. package/src/sources/HyperSyncSource.res.mjs +106 -121
  146. package/src/sources/Rpc.res +86 -14
  147. package/src/sources/Rpc.res.mjs +101 -9
  148. package/src/sources/RpcSource.res +731 -364
  149. package/src/sources/RpcSource.res.mjs +845 -410
  150. package/src/sources/RpcWebSocketHeightStream.res +181 -0
  151. package/src/sources/RpcWebSocketHeightStream.res.mjs +196 -0
  152. package/src/sources/SimulateSource.res +59 -0
  153. package/src/sources/SimulateSource.res.mjs +50 -0
  154. package/src/sources/Source.res +7 -5
  155. package/src/sources/SourceManager.res +358 -221
  156. package/src/sources/SourceManager.res.mjs +346 -171
  157. package/src/sources/SourceManager.resi +17 -6
  158. package/src/sources/Svm.res +81 -0
  159. package/src/sources/Svm.res.mjs +90 -0
  160. package/src/tui/Tui.res +247 -0
  161. package/src/tui/Tui.res.mjs +337 -0
  162. package/src/tui/bindings/Ink.res +371 -0
  163. package/src/tui/bindings/Ink.res.mjs +72 -0
  164. package/src/tui/bindings/Style.res +123 -0
  165. package/src/tui/bindings/Style.res.mjs +2 -0
  166. package/src/tui/components/BufferedProgressBar.res +40 -0
  167. package/src/tui/components/BufferedProgressBar.res.mjs +57 -0
  168. package/src/tui/components/CustomHooks.res +122 -0
  169. package/src/tui/components/CustomHooks.res.mjs +179 -0
  170. package/src/tui/components/Messages.res +41 -0
  171. package/src/tui/components/Messages.res.mjs +75 -0
  172. package/src/tui/components/SyncETA.res +174 -0
  173. package/src/tui/components/SyncETA.res.mjs +263 -0
  174. package/src/tui/components/TuiData.res +47 -0
  175. package/src/tui/components/TuiData.res.mjs +34 -0
  176. package/svm.schema.json +112 -0
  177. package/bin.js +0 -48
  178. package/src/EventRegister.res +0 -241
  179. package/src/EventRegister.res.mjs +0 -240
  180. package/src/EventRegister.resi +0 -30
  181. package/src/bindings/Ethers.gen.ts +0 -14
  182. package/src/bindings/Ethers.res +0 -204
  183. package/src/bindings/Ethers.res.mjs +0 -130
  184. /package/src/{Indexer.res.mjs → Ctx.res.mjs} +0 -0
@@ -2,27 +2,57 @@ open Belt
2
2
 
3
3
  type sourceManagerStatus = Idle | WaitingForNewBlock | Querieng
4
4
 
5
+ type sourceState = {
6
+ source: Source.t,
7
+ mutable knownHeight: int,
8
+ mutable unsubscribe: option<unit => unit>,
9
+ mutable pendingHeightResolvers: array<int => unit>,
10
+ mutable disabled: bool,
11
+ // Timestamp (ms) when this source last failed during executeQuery.
12
+ // Used to decide when to attempt recovery to this source.
13
+ mutable lastFailedAt: option<float>,
14
+ }
15
+
5
16
  // Ideally the ChainFetcher name suits this better
6
17
  // But currently the ChainFetcher module is immutable
7
18
  // and handles both processing and fetching.
8
19
  // So this module is to encapsulate the fetching logic only
9
20
  // with a mutable state for easier reasoning and testing.
10
21
  type t = {
11
- sources: Utils.Set.t<Source.t>,
22
+ sourcesState: array<sourceState>,
12
23
  mutable statusStart: Hrtime.timeRef,
13
24
  mutable status: sourceManagerStatus,
14
25
  maxPartitionConcurrency: int,
15
- newBlockFallbackStallTimeout: int,
26
+ newBlockStallTimeout: int,
27
+ newBlockStallTimeoutLive: int,
16
28
  stalledPollingInterval: int,
17
29
  getHeightRetryInterval: (~retry: int) => int,
18
30
  mutable activeSource: Source.t,
19
31
  mutable waitingForNewBlockStateId: option<int>,
20
32
  // Should take into consideration partitions fetching for previous states (before rollback)
21
33
  mutable fetchingPartitionsCount: int,
34
+ recoveryTimeout: float,
35
+ mutable hasLive: bool,
22
36
  }
23
37
 
24
38
  let getActiveSource = sourceManager => sourceManager.activeSource
25
39
 
40
+ type sourceRole = Primary | Secondary
41
+
42
+ // Determines whether a source is Primary or Secondary given the current mode.
43
+ // isLive=false (backfill): Sync=Primary, Fallback=Secondary, Live=ignored (None).
44
+ // isLive=true with hasLive: Live=Primary, Sync+Fallback=Secondary.
45
+ // isLive=true without hasLive: Sync=Primary, Fallback=Secondary.
46
+ let getSourceRole = (~sourceFor: Source.sourceFor, ~isLive, ~hasLive) =>
47
+ switch (isLive, sourceFor) {
48
+ | (false, Sync) => Some(Primary)
49
+ | (false, Fallback) => Some(Secondary)
50
+ | (false, Live) => None
51
+ | (true, Live) => Some(Primary)
52
+ | (true, Sync) => hasLive ? Some(Secondary) : Some(Primary)
53
+ | (true, Fallback) => Some(Secondary)
54
+ }
55
+
26
56
  let makeGetHeightRetryInterval = (
27
57
  ~initialRetryInterval,
28
58
  ~backoffMultiplicative,
@@ -41,17 +71,23 @@ let makeGetHeightRetryInterval = (
41
71
  let make = (
42
72
  ~sources: array<Source.t>,
43
73
  ~maxPartitionConcurrency,
44
- ~newBlockFallbackStallTimeout=20_000,
74
+ ~isLive,
75
+ ~newBlockStallTimeout=60_000,
76
+ ~newBlockStallTimeoutLive=20_000,
45
77
  ~stalledPollingInterval=5_000,
78
+ ~recoveryTimeout=60_000.0,
46
79
  ~getHeightRetryInterval=makeGetHeightRetryInterval(
47
80
  ~initialRetryInterval=1000,
48
81
  ~backoffMultiplicative=2,
49
82
  ~maxRetryInterval=60_000,
50
83
  ),
51
84
  ) => {
52
- let initialActiveSource = switch sources->Js.Array2.find(source => source.sourceFor === Sync) {
53
- | None => Js.Exn.raiseError("Invalid configuration, no data-source for historical sync provided")
85
+ let hasLive = sources->Js.Array2.some(s => s.sourceFor === Live)
86
+ let initialActiveSource = switch sources->Js.Array2.find(source =>
87
+ getSourceRole(~sourceFor=source.sourceFor, ~isLive, ~hasLive) === Some(Primary)
88
+ ) {
54
89
  | Some(source) => source
90
+ | None => Js.Exn.raiseError("Invalid configuration, no data-source for historical sync provided")
55
91
  }
56
92
  Prometheus.IndexingMaxConcurrency.set(
57
93
  ~maxConcurrency=maxPartitionConcurrency,
@@ -63,15 +99,25 @@ let make = (
63
99
  )
64
100
  {
65
101
  maxPartitionConcurrency,
66
- sources: Utils.Set.fromArray(sources),
102
+ sourcesState: sources->Array.map(source => {
103
+ source,
104
+ knownHeight: 0,
105
+ unsubscribe: None,
106
+ pendingHeightResolvers: [],
107
+ disabled: false,
108
+ lastFailedAt: None,
109
+ }),
67
110
  activeSource: initialActiveSource,
68
111
  waitingForNewBlockStateId: None,
69
112
  fetchingPartitionsCount: 0,
70
- newBlockFallbackStallTimeout,
113
+ newBlockStallTimeout,
114
+ newBlockStallTimeoutLive,
71
115
  stalledPollingInterval,
72
116
  getHeightRetryInterval,
117
+ recoveryTimeout,
73
118
  statusStart: Hrtime.makeTimer(),
74
119
  status: Idle,
120
+ hasLive,
75
121
  }
76
122
  }
77
123
 
@@ -81,9 +127,9 @@ let trackNewStatus = (sourceManager: t, ~newStatus) => {
81
127
  | WaitingForNewBlock => Prometheus.IndexingSourceWaitingTime.counter
82
128
  | Querieng => Prometheus.IndexingQueryTime.counter
83
129
  }
84
- promCounter->Prometheus.SafeCounter.incrementMany(
130
+ promCounter->Prometheus.SafeCounter.handleFloat(
85
131
  ~labels=sourceManager.activeSource.chain->ChainMap.Chain.toChainId,
86
- ~value=sourceManager.statusStart->Hrtime.timeSince->Hrtime.toMillis->Hrtime.intFromMillis,
132
+ ~value=sourceManager.statusStart->Hrtime.timeSince->Hrtime.toSecondsFloat,
87
133
  )
88
134
  sourceManager.statusStart = Hrtime.makeTimer()
89
135
  sourceManager.status = newStatus
@@ -92,7 +138,6 @@ let trackNewStatus = (sourceManager: t, ~newStatus) => {
92
138
  let fetchNext = async (
93
139
  sourceManager: t,
94
140
  ~fetchState: FetchState.t,
95
- ~currentBlockHeight,
96
141
  ~executeQuery,
97
142
  ~waitForNewBlock,
98
143
  ~onNewBlock,
@@ -100,13 +145,13 @@ let fetchNext = async (
100
145
  ) => {
101
146
  let {maxPartitionConcurrency} = sourceManager
102
147
 
103
- switch fetchState->FetchState.getNextQuery(
148
+ let nextQuery = fetchState->FetchState.getNextQuery(
104
149
  ~concurrencyLimit={
105
150
  maxPartitionConcurrency - sourceManager.fetchingPartitionsCount
106
151
  },
107
- ~currentBlockHeight,
108
- ~stateId,
109
- ) {
152
+ )
153
+
154
+ switch nextQuery {
110
155
  | ReachedMaxConcurrency
111
156
  | NothingToQuery => ()
112
157
  | WaitingForNewBlock =>
@@ -116,19 +161,19 @@ let fetchNext = async (
116
161
  | None =>
117
162
  sourceManager->trackNewStatus(~newStatus=WaitingForNewBlock)
118
163
  sourceManager.waitingForNewBlockStateId = Some(stateId)
119
- let currentBlockHeight = await waitForNewBlock(~currentBlockHeight)
164
+ let knownHeight = await waitForNewBlock(~knownHeight=fetchState.knownHeight)
120
165
  switch sourceManager.waitingForNewBlockStateId {
121
166
  | Some(waitingStateId) if waitingStateId === stateId => {
122
167
  sourceManager->trackNewStatus(~newStatus=Idle)
123
168
  sourceManager.waitingForNewBlockStateId = None
124
- onNewBlock(~currentBlockHeight)
169
+ onNewBlock(~knownHeight)
125
170
  }
126
171
  | Some(_) // Don't reset it if we are waiting for another state
127
172
  | None => ()
128
173
  }
129
174
  }
130
175
  | Ready(queries) => {
131
- fetchState->FetchState.startFetchingQueries(~queries, ~stateId)
176
+ fetchState->FetchState.startFetchingQueries(~queries)
132
177
  sourceManager.fetchingPartitionsCount =
133
178
  sourceManager.fetchingPartitionsCount + queries->Array.length
134
179
  Prometheus.IndexingConcurrency.set(
@@ -159,131 +204,279 @@ let fetchNext = async (
159
204
 
160
205
  type status = Active | Stalled | Done
161
206
 
207
+ let disableSource = (sourceManager: t, sourceState: sourceState) => {
208
+ if !sourceState.disabled {
209
+ sourceState.disabled = true
210
+ switch sourceState.unsubscribe {
211
+ | Some(unsubscribe) => unsubscribe()
212
+ | None => ()
213
+ }
214
+ if sourceState.source.sourceFor === Live {
215
+ // Only clear hasLive if no other non-disabled Live sources remain
216
+ let hasOtherLive =
217
+ sourceManager.sourcesState->Js.Array2.some(s =>
218
+ s !== sourceState && !s.disabled && s.source.sourceFor === Live
219
+ )
220
+ sourceManager.hasLive = hasOtherLive
221
+ }
222
+ true
223
+ } else {
224
+ false
225
+ }
226
+ }
227
+
162
228
  let getSourceNewHeight = async (
163
229
  sourceManager,
164
- ~source: Source.t,
165
- ~currentBlockHeight,
230
+ ~sourceState: sourceState,
231
+ ~knownHeight,
232
+ ~stallTimeout,
233
+ ~isLive,
166
234
  ~status: ref<status>,
167
235
  ~logger,
168
236
  ) => {
169
- let newHeight = ref(0)
237
+ let source = sourceState.source
238
+ let initialHeight = sourceState.knownHeight
239
+ let newHeight = ref(initialHeight)
170
240
  let retry = ref(0)
171
241
 
172
- while newHeight.contents <= currentBlockHeight && status.contents !== Done {
173
- try {
174
- // Use to detect if the source is taking too long to respond
175
- let endTimer = Prometheus.SourceGetHeightDuration.startTimer({
176
- "source": source.name,
177
- "chainId": source.chain->ChainMap.Chain.toChainId,
242
+ while newHeight.contents <= knownHeight && status.contents !== Done {
243
+ // If subscription exists, wait for next height event
244
+ switch sourceState.unsubscribe {
245
+ | Some(_) =>
246
+ let subscriptionPromise = Promise.make((resolve, _reject) => {
247
+ sourceState.pendingHeightResolvers->Array.push(resolve)
178
248
  })
179
- let height = await source.getHeightOrThrow()
180
- endTimer()
181
-
182
- newHeight := height
183
- if height <= currentBlockHeight {
184
- retry := 0
185
- // Slowdown polling when the chain isn't progressing
186
- let pollingInterval = if status.contents === Stalled {
187
- sourceManager.stalledPollingInterval
188
- } else {
189
- source.pollingInterval
249
+ // If subscription goes quiet for half the stall timeout, fall back to REST polling
250
+ let pollingFallback = Utils.delay(stallTimeout / 2)->Promise.then(async () => {
251
+ logger->Logging.childTrace({
252
+ "msg": "onHeight subscription stale, switching to polling fallback",
253
+ "source": source.name,
254
+ "chainId": source.chain->ChainMap.Chain.toChainId,
255
+ })
256
+ let h = ref(initialHeight)
257
+ while h.contents <= knownHeight && !(newHeight.contents > initialHeight) {
258
+ try {
259
+ h := (await source.getHeightOrThrow())
260
+ } catch {
261
+ | _ => ()
262
+ }
263
+ if h.contents <= knownHeight && !(newHeight.contents > initialHeight) {
264
+ await Utils.delay(source.pollingInterval)
265
+ }
190
266
  }
191
- await Utils.delay(pollingInterval)
192
- }
193
- } catch {
194
- | exn =>
195
- let retryInterval = sourceManager.getHeightRetryInterval(~retry=retry.contents)
196
- logger->Logging.childTrace({
197
- "msg": `Height retrieval from ${source.name} source failed. Retrying in ${retryInterval->Int.toString}ms.`,
198
- "source": source.name,
199
- "err": exn->Utils.prettifyExn,
267
+ h.contents
200
268
  })
201
- retry := retry.contents + 1
202
- await Utils.delay(retryInterval)
269
+ let height = await Promise.race([subscriptionPromise, pollingFallback])
270
+
271
+ // Only accept heights greater than initialHeight
272
+ if height > initialHeight {
273
+ newHeight := height
274
+ }
275
+ | None =>
276
+ // No subscription, use REST polling
277
+ try {
278
+ let height = await source.getHeightOrThrow()
279
+
280
+ newHeight := height
281
+ if height <= knownHeight {
282
+ retry := 0
283
+
284
+ // If createHeightSubscription is available and height hasn't changed,
285
+ // create subscription instead of polling
286
+ switch source.createHeightSubscription {
287
+ | Some(createSubscription) if isLive =>
288
+ let unsubscribe = createSubscription(~onHeight=newHeight => {
289
+ sourceState.knownHeight = newHeight
290
+ // Resolve all pending height resolvers
291
+ let resolvers = sourceState.pendingHeightResolvers
292
+ sourceState.pendingHeightResolvers = []
293
+ resolvers->Array.forEach(resolve => resolve(newHeight))
294
+ })
295
+ sourceState.unsubscribe = Some(unsubscribe)
296
+ | _ =>
297
+ // Slowdown polling when the chain isn't progressing
298
+ let pollingInterval = if status.contents === Stalled {
299
+ sourceManager.stalledPollingInterval
300
+ } else {
301
+ source.pollingInterval
302
+ }
303
+ await Utils.delay(pollingInterval)
304
+ }
305
+ }
306
+ } catch {
307
+ | exn =>
308
+ let retryInterval = sourceManager.getHeightRetryInterval(~retry=retry.contents)
309
+ logger->Logging.childTrace({
310
+ "msg": `Height retrieval from ${source.name} source failed. Retrying in ${retryInterval->Int.toString}ms.`,
311
+ "source": source.name,
312
+ "err": exn->Utils.prettifyExn,
313
+ })
314
+ retry := retry.contents + 1
315
+ await Utils.delay(retryInterval)
316
+ }
203
317
  }
204
318
  }
205
- Prometheus.SourceHeight.set(
206
- ~sourceName=source.name,
207
- ~chainId=source.chain->ChainMap.Chain.toChainId,
208
- ~blockNumber=newHeight.contents,
209
- )
319
+
320
+ // Update Prometheus only if height increased
321
+ if newHeight.contents > initialHeight {
322
+ Prometheus.SourceHeight.set(
323
+ ~sourceName=source.name,
324
+ ~chainId=source.chain->ChainMap.Chain.toChainId,
325
+ ~blockNumber=newHeight.contents,
326
+ )
327
+ }
328
+
210
329
  newHeight.contents
211
330
  }
212
331
 
332
+ let compareByOldestFailure = (a: sourceState, b: sourceState) =>
333
+ switch (a.lastFailedAt, b.lastFailedAt) {
334
+ | (None, Some(_)) => -1
335
+ | (Some(_), None) => 1
336
+ | (Some(a), Some(b)) => a < b ? -1 : a > b ? 1 : 0
337
+ | (None, None) => 0
338
+ }
339
+
340
+ // Priority: working primaries > working secondaries > all primaries.
341
+ let getNextSources = (sourceManager, ~isLive, ~excludedSources=?) => {
342
+ let now = Js.Date.now()
343
+ let workingPrimarySources = []
344
+ let allPrimarySources = []
345
+ let workingSecondarySources = []
346
+ for i in 0 to sourceManager.sourcesState->Array.length - 1 {
347
+ let sourceState = sourceManager.sourcesState->Array.getUnsafe(i)
348
+ if !sourceState.disabled {
349
+ let isExcluded = switch excludedSources {
350
+ | Some(set) => set->Utils.Set.has(sourceState)
351
+ | None => false
352
+ }
353
+ if !isExcluded {
354
+ let isWorking = switch sourceState.lastFailedAt {
355
+ | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
356
+ | None => true
357
+ }
358
+ switch getSourceRole(
359
+ ~sourceFor=sourceState.source.sourceFor,
360
+ ~isLive,
361
+ ~hasLive=sourceManager.hasLive,
362
+ ) {
363
+ | Some(Primary) =>
364
+ allPrimarySources->Array.push(sourceState)
365
+ if isWorking {
366
+ workingPrimarySources->Array.push(sourceState)
367
+ }
368
+ | Some(Secondary) if isWorking => workingSecondarySources->Array.push(sourceState)
369
+ | _ => ()
370
+ }
371
+ }
372
+ }
373
+ }
374
+ if workingPrimarySources->Array.length > 0 {
375
+ workingPrimarySources
376
+ } else if workingSecondarySources->Array.length > 0 {
377
+ workingSecondarySources
378
+ } else {
379
+ // All primaries in recovery — sort by oldest lastFailedAt (closest to recovery first)
380
+ allPrimarySources->Js.Array2.sortInPlaceWith(compareByOldestFailure)
381
+ }
382
+ }
383
+
384
+ // Single source selection from getNextSources.
385
+ // Prefers activeSource if it's in the candidates. Fast path: check first item.
386
+ let getNextSource = (sourceManager, ~isLive, ~excludedSources=?) => {
387
+ let sources = sourceManager->getNextSources(~isLive, ~excludedSources?)
388
+ switch sources->Array.get(0) {
389
+ | None => None
390
+ | Some(first) if first.source === sourceManager.activeSource => Some(first)
391
+ | _ =>
392
+ switch sources->Js.Array2.find(s => s.source === sourceManager.activeSource) {
393
+ | Some(_) as result => result
394
+ | None => sources->Array.get(0)
395
+ }
396
+ }
397
+ }
398
+
213
399
  // Polls for a block height greater than the given block number to ensure a new block is available for indexing.
214
- let waitForNewBlock = async (sourceManager: t, ~currentBlockHeight) => {
215
- let {sources} = sourceManager
400
+ let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isLive) => {
401
+ let {sourcesState} = sourceManager
216
402
 
217
403
  let logger = Logging.createChild(
218
404
  ~params={
219
405
  "chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId,
220
- "currentBlockHeight": currentBlockHeight,
406
+ "knownHeight": knownHeight,
221
407
  },
222
408
  )
223
409
  logger->Logging.childTrace("Initiating check for new blocks.")
224
410
 
225
- // Only include Live sources if we've actually synced some blocks
226
- // (currentBlockHeight > 0 means we've fetched at least one batch)
227
- // This prevents Live RPC from winning the initial height race and
228
- // becoming activeSource, which would bypass HyperSync's smart block detection
229
- let isInitialHeightFetch = currentBlockHeight === 0
230
-
231
- let syncSources = []
232
- let fallbackSources = []
233
- sources->Utils.Set.forEach(source => {
234
- if (
235
- source.sourceFor === Sync ||
236
- // Include Live sources only after initial sync has started
237
- // Live sources are optimized for real-time indexing with lower latency
238
- (source.sourceFor === Live && !isInitialHeightFetch) ||
239
- // Even if the active source is a fallback, still include
240
- // it to the list. So we don't wait for a timeout again
241
- // if all main sync sources are still not valid
242
- source === sourceManager.activeSource
243
- ) {
244
- syncSources->Array.push(source)
245
- } else {
246
- fallbackSources->Array.push(source)
247
- }
248
- })
411
+ let mainSources = sourceManager->getNextSources(~isLive)
249
412
 
250
413
  let status = ref(Active)
251
414
 
415
+ let stallTimeout = if isLive {
416
+ sourceManager.newBlockStallTimeoutLive
417
+ } else {
418
+ sourceManager.newBlockStallTimeout
419
+ }
420
+
252
421
  let (source, newBlockHeight) = await Promise.race(
253
- syncSources
254
- ->Array.map(async source => {
422
+ mainSources
423
+ ->Array.map(async sourceState => {
255
424
  (
256
- source,
257
- await sourceManager->getSourceNewHeight(~source, ~currentBlockHeight, ~status, ~logger),
425
+ sourceState.source,
426
+ await sourceManager->getSourceNewHeight(
427
+ ~sourceState,
428
+ ~knownHeight,
429
+ ~stallTimeout,
430
+ ~isLive,
431
+ ~status,
432
+ ~logger,
433
+ ),
258
434
  )
259
435
  })
260
436
  ->Array.concat([
261
- Utils.delay(sourceManager.newBlockFallbackStallTimeout)->Promise.then(() => {
437
+ Utils.delay(stallTimeout)->Promise.then(() => {
438
+ // Build fallback: sources not in mainSources with a valid role, even with recent lastFailedAt
439
+ let fallbackSources = []
440
+ sourcesState->Array.forEach(sourceState => {
441
+ if (
442
+ !(mainSources->Js.Array2.includes(sourceState)) &&
443
+ getSourceRole(
444
+ ~sourceFor=sourceState.source.sourceFor,
445
+ ~isLive,
446
+ ~hasLive=sourceManager.hasLive,
447
+ )->Option.isSome
448
+ ) {
449
+ fallbackSources->Array.push(sourceState)
450
+ }
451
+ })
452
+
262
453
  if status.contents !== Done {
263
454
  status := Stalled
264
455
 
265
456
  switch fallbackSources {
266
457
  | [] =>
267
458
  logger->Logging.childWarn(
268
- `No new blocks detected within ${(sourceManager.newBlockFallbackStallTimeout / 1000)
459
+ `No new blocks detected within ${(stallTimeout / 1000)
269
460
  ->Int.toString}s. Polling will continue at a reduced rate. For better reliability, refer to our RPC fallback guide: https://docs.envio.dev/docs/HyperIndex/rpc-sync`,
270
461
  )
271
462
  | _ =>
272
463
  logger->Logging.childWarn(
273
- `No new blocks detected within ${(sourceManager.newBlockFallbackStallTimeout / 1000)
274
- ->Int.toString}s. Continuing polling with fallback RPC sources from the configuration.`,
464
+ `No new blocks detected within ${(stallTimeout / 1000)
465
+ ->Int.toString}s. Continuing polling with secondary RPC sources from the configuration.`,
275
466
  )
276
467
  }
277
468
  }
278
469
  // Promise.race will be forever pending if fallbackSources is empty
279
470
  // which is good for this use case
280
471
  Promise.race(
281
- fallbackSources->Array.map(async source => {
472
+ fallbackSources->Array.map(async sourceState => {
282
473
  (
283
- source,
474
+ sourceState.source,
284
475
  await sourceManager->getSourceNewHeight(
285
- ~source,
286
- ~currentBlockHeight,
476
+ ~sourceState,
477
+ ~knownHeight,
478
+ ~stallTimeout,
479
+ ~isLive,
287
480
  ~status,
288
481
  ~logger,
289
482
  ),
@@ -296,7 +489,7 @@ let waitForNewBlock = async (sourceManager: t, ~currentBlockHeight) => {
296
489
 
297
490
  sourceManager.activeSource = source
298
491
 
299
- // Show a higher level log if we displayed a warning/error after newBlockFallbackStallTimeout
492
+ // Show a higher level log if we displayed a warning/error after newBlockStallTimeout
300
493
  let log = status.contents === Stalled ? Logging.childInfo : Logging.childTrace
301
494
  logger->log({
302
495
  "msg": `New blocks successfully found.`,
@@ -309,63 +502,43 @@ let waitForNewBlock = async (sourceManager: t, ~currentBlockHeight) => {
309
502
  newBlockHeight
310
503
  }
311
504
 
312
- let getNextSyncSource = (
313
- sourceManager,
314
- // This is needed to include the Fallback source to rotation
315
- ~initialSource,
316
- ~currentSource,
317
- // After multiple failures start returning fallback sources as well
318
- // But don't try it when main sync sources fail because of invalid configuration
319
- // note: The logic might be changed in the future
320
- ~attemptFallbacks=false,
321
- ) => {
322
- let before = []
323
- let after = []
324
-
325
- let hasActive = ref(false)
326
-
327
- sourceManager.sources->Utils.Set.forEach(source => {
328
- if source === currentSource {
329
- hasActive := true
330
- } else if (
331
- switch source.sourceFor {
332
- | Sync => true
333
- // Live sources should NOT be used for historical sync rotation
334
- // They are only meant for real-time indexing once synced
335
- | Live | Fallback => attemptFallbacks || source === initialSource
336
- }
337
- ) {
338
- (hasActive.contents ? after : before)->Array.push(source)
339
- }
340
- })
505
+ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~knownHeight, ~isLive) => {
506
+ 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."
341
507
 
342
- switch after->Array.get(0) {
343
- | Some(s) => s
344
- | None =>
345
- switch before->Array.get(0) {
346
- | Some(s) => s
347
- | None => currentSource
348
- }
349
- }
350
- }
508
+ // Sources where the query is impossible — lazily allocated, excluded for the duration of this query
509
+ let excludedSourcesRef = ref(None)
351
510
 
352
- let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBlockHeight) => {
353
- let toBlockRef = ref(
354
- switch query.target {
355
- | Head => None
356
- | EndBlock({toBlock})
357
- | Merge({toBlock}) =>
358
- Some(toBlock)
359
- },
360
- )
511
+ let toBlockRef = ref(query.toBlock)
361
512
  let responseRef = ref(None)
362
513
  let retryRef = ref(0)
363
- let initialSource = sourceManager.activeSource
364
- let sourceRef = ref(initialSource)
365
- let shouldUpdateActiveSource = ref(false)
366
514
 
367
515
  while responseRef.contents->Option.isNone {
368
- let source = sourceRef.contents
516
+ // Select the best source at the start of every iteration
517
+ let sourceState = switch sourceManager->getNextSource(
518
+ ~isLive,
519
+ ~excludedSources=?excludedSourcesRef.contents,
520
+ ) {
521
+ | Some(s) =>
522
+ if s.source !== sourceManager.activeSource {
523
+ let logger = Logging.createChild(
524
+ ~params={"chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId},
525
+ )
526
+ logger->Logging.childInfo({
527
+ "msg": "Switching data-source",
528
+ "source": s.source.name,
529
+ "previousSource": sourceManager.activeSource.name,
530
+ "fromBlock": query.fromBlock,
531
+ })
532
+ }
533
+ s
534
+ | None =>
535
+ let logger = Logging.createChild(
536
+ ~params={"chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId},
537
+ )
538
+ %raw(`null`)->ErrorHandling.mkLogAndRaise(~logger, ~msg=noSourcesError)
539
+ }
540
+ sourceManager.activeSource = sourceState.source
541
+ let source = sourceState.source
369
542
  let toBlock = toBlockRef.contents
370
543
  let retry = retryRef.contents
371
544
 
@@ -389,7 +562,7 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
389
562
  ~addressesByContractName=query.addressesByContractName,
390
563
  ~indexingContracts=query.indexingContracts,
391
564
  ~partitionId=query.partitionId,
392
- ~currentBlockHeight,
565
+ ~knownHeight,
393
566
  ~selection=query.selection,
394
567
  ~retry,
395
568
  ~logger,
@@ -400,21 +573,20 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
400
573
  "numEvents": response.parsedQueueItems->Array.length,
401
574
  "stats": response.stats,
402
575
  })
576
+ sourceState.lastFailedAt = None
403
577
  responseRef := Some(response)
404
578
  } catch {
405
579
  | Source.GetItemsError(error) =>
406
580
  switch error {
407
581
  | UnsupportedSelection(_)
408
582
  | FailedGettingFieldSelection(_) => {
409
- let nextSource = sourceManager->getNextSyncSource(~initialSource, ~currentSource=source)
410
-
411
- // These errors are impossible to recover, so we delete the source
412
- // from sourceManager so it's not attempted anymore
413
- let notAlreadyDeleted = sourceManager.sources->Utils.Set.delete(source)
583
+ // These errors are impossible to recover, so we disable the source
584
+ // so it's not attempted anymore
585
+ let notAlreadyDisabled = sourceManager->disableSource(sourceState)
414
586
 
415
587
  // In case there are multiple partitions
416
588
  // failing at the same time. Log only once
417
- if notAlreadyDeleted {
589
+ if notAlreadyDisabled {
418
590
  switch error {
419
591
  | UnsupportedSelection({message}) => logger->Logging.childError(message)
420
592
  | FailedGettingFieldSelection({exn, message, blockNumber, logIndex}) =>
@@ -428,20 +600,7 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
428
600
  }
429
601
  }
430
602
 
431
- if nextSource === source {
432
- %raw(`null`)->ErrorHandling.mkLogAndRaise(
433
- ~logger,
434
- ~msg="The indexer doesn't have data-sources which can continue fetching. Please, check the error logs or reach out to the Envio team.",
435
- )
436
- } else {
437
- logger->Logging.childInfo({
438
- "msg": "Switching to another data-source",
439
- "source": nextSource.name,
440
- })
441
- sourceRef := nextSource
442
- shouldUpdateActiveSource := true
443
- retryRef := 0
444
- }
603
+ retryRef := 0
445
604
  }
446
605
  | FailedGettingItems({attemptedToBlock, retry: WithSuggestedToBlock({toBlock})}) =>
447
606
  logger->Logging.childTrace({
@@ -452,58 +611,24 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
452
611
  toBlockRef := Some(toBlock)
453
612
  retryRef := 0
454
613
  | FailedGettingItems({exn, attemptedToBlock, retry: ImpossibleForTheQuery({message})}) =>
455
- let nextSource =
456
- sourceManager->getNextSyncSource(
457
- ~initialSource,
458
- ~currentSource=source,
459
- ~attemptFallbacks=true,
460
- )
461
-
462
- let hasAnotherSource = nextSource !== initialSource
614
+ // Don't set lastFailedAt — the source isn't broken, the query just can't work on it
615
+ let excludedSources = switch excludedSourcesRef.contents {
616
+ | Some(s) => s
617
+ | None =>
618
+ let s = Utils.Set.make()
619
+ excludedSourcesRef := Some(s)
620
+ s
621
+ }
622
+ excludedSources->Utils.Set.add(sourceState)->ignore
463
623
 
464
624
  logger->Logging.childWarn({
465
- "msg": message ++ (hasAnotherSource ? " - Attempting to another source" : ""),
625
+ "msg": message ++ " - Attempting another source",
466
626
  "toBlock": attemptedToBlock,
467
627
  "err": exn->Utils.prettifyExn,
468
628
  })
469
-
470
- if !hasAnotherSource {
471
- %raw(`null`)->ErrorHandling.mkLogAndRaise(
472
- ~logger,
473
- ~msg="The indexer doesn't have data-sources which can continue fetching. Please, check the error logs or reach out to the Envio team.",
474
- )
475
- } else {
476
- sourceRef := nextSource
477
- shouldUpdateActiveSource := false
478
- retryRef := 0
479
- }
629
+ retryRef := 0
480
630
 
481
631
  | FailedGettingItems({exn, attemptedToBlock, retry: WithBackoff({message, backoffMillis})}) =>
482
- // Starting from the 11th failure (retry=10)
483
- // include fallback sources for switch
484
- // (previously it would consider only sync sources or the initial one)
485
- // This is a little bit tricky to find the right number,
486
- // because meaning between RPC and HyperSync is different for the error
487
- // but since Fallback was initially designed to be used only for height check
488
- // just keep the value high
489
- let attemptFallbacks = retry >= 10
490
-
491
- let nextSource = switch retry {
492
- // Don't attempt a switch on first two failure
493
- | 0 | 1 => source
494
- | _ =>
495
- // Then try to switch every second failure
496
- if retry->mod(2) === 0 {
497
- sourceManager->getNextSyncSource(
498
- ~initialSource,
499
- ~attemptFallbacks,
500
- ~currentSource=source,
501
- )
502
- } else {
503
- source
504
- }
505
- }
506
-
507
632
  // Start displaying warnings after 4 failures
508
633
  let log = retry >= 4 ? Logging.childWarn : Logging.childTrace
509
634
  logger->log({
@@ -514,14 +639,30 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
514
639
  "err": exn->Utils.prettifyExn,
515
640
  })
516
641
 
517
- let shouldSwitch = nextSource !== source
642
+ let shouldSwitch = switch retry {
643
+ // Don't attempt a switch on first two failures
644
+ | 0 | 1 => false
645
+ // Then try to switch every second failure
646
+ | _ => retry->mod(2) === 0
647
+ }
648
+
518
649
  if shouldSwitch {
519
- logger->Logging.childInfo({
520
- "msg": "Switching to another data-source",
521
- "source": nextSource.name,
522
- })
523
- sourceRef := nextSource
524
- shouldUpdateActiveSource := true
650
+ let now = Js.Date.now()
651
+ sourceState.lastFailedAt = Some(now)
652
+ // Check if there's a working (recovered) source to switch to immediately
653
+ let nextSource =
654
+ sourceManager->getNextSource(~isLive, ~excludedSources=?excludedSourcesRef.contents)
655
+ let hasWorkingAlternative = switch nextSource {
656
+ | Some(s) =>
657
+ switch s.lastFailedAt {
658
+ | None => true
659
+ | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
660
+ }
661
+ | None => false
662
+ }
663
+ if !hasWorkingAlternative {
664
+ await Utils.delay(Pervasives.min(backoffMillis, 60_000))
665
+ }
525
666
  } else {
526
667
  await Utils.delay(Pervasives.min(backoffMillis, 60_000))
527
668
  }
@@ -533,9 +674,5 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
533
674
  }
534
675
  }
535
676
 
536
- if shouldUpdateActiveSource.contents {
537
- sourceManager.activeSource = sourceRef.contents
538
- }
539
-
540
677
  responseRef.contents->Option.getUnsafe
541
678
  }