envio 3.0.0-alpha.2 → 3.0.0-alpha.20

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 (175) 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 +497 -1
  6. package/index.js +4 -0
  7. package/package.json +42 -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 +737 -22
  18. package/src/Config.res.mjs +703 -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 +30 -74
  23. package/src/Env.res.mjs +25 -87
  24. package/src/Envio.gen.ts +3 -1
  25. package/src/Envio.res +20 -9
  26. package/src/EventProcessing.res +469 -0
  27. package/src/EventProcessing.res.mjs +337 -0
  28. package/src/EvmTypes.gen.ts +6 -0
  29. package/src/EvmTypes.res +1 -0
  30. package/src/FetchState.res +1256 -639
  31. package/src/FetchState.res.mjs +1135 -612
  32. package/src/GlobalState.res +1190 -0
  33. package/src/GlobalState.res.mjs +1183 -0
  34. package/src/GlobalStateManager.res +68 -0
  35. package/src/GlobalStateManager.res.mjs +75 -0
  36. package/src/GlobalStateManager.resi +7 -0
  37. package/src/HandlerLoader.res +89 -0
  38. package/src/HandlerLoader.res.mjs +79 -0
  39. package/src/HandlerRegister.res +357 -0
  40. package/src/HandlerRegister.res.mjs +299 -0
  41. package/src/{EventRegister.resi → HandlerRegister.resi} +13 -13
  42. package/src/Hasura.res +111 -175
  43. package/src/Hasura.res.mjs +88 -150
  44. package/src/InMemoryStore.res +1 -1
  45. package/src/InMemoryStore.res.mjs +3 -3
  46. package/src/InMemoryTable.res +1 -1
  47. package/src/InMemoryTable.res.mjs +1 -1
  48. package/src/Internal.gen.ts +4 -0
  49. package/src/Internal.res +230 -12
  50. package/src/Internal.res.mjs +115 -1
  51. package/src/LoadLayer.res +444 -0
  52. package/src/LoadLayer.res.mjs +296 -0
  53. package/src/LoadLayer.resi +32 -0
  54. package/src/LogSelection.res +33 -27
  55. package/src/LogSelection.res.mjs +6 -0
  56. package/src/Logging.res +21 -7
  57. package/src/Logging.res.mjs +16 -8
  58. package/src/Main.res +377 -0
  59. package/src/Main.res.mjs +339 -0
  60. package/src/Persistence.res +7 -21
  61. package/src/Persistence.res.mjs +3 -3
  62. package/src/PgStorage.gen.ts +10 -0
  63. package/src/PgStorage.res +116 -69
  64. package/src/PgStorage.res.d.mts +5 -0
  65. package/src/PgStorage.res.mjs +93 -50
  66. package/src/Prometheus.res +294 -224
  67. package/src/Prometheus.res.mjs +353 -340
  68. package/src/ReorgDetection.res +6 -10
  69. package/src/ReorgDetection.res.mjs +6 -6
  70. package/src/SafeCheckpointTracking.res +4 -4
  71. package/src/SafeCheckpointTracking.res.mjs +2 -2
  72. package/src/Sink.res +4 -2
  73. package/src/Sink.res.mjs +2 -1
  74. package/src/TableIndices.res +0 -1
  75. package/src/TestIndexer.res +692 -0
  76. package/src/TestIndexer.res.mjs +527 -0
  77. package/src/TestIndexerProxyStorage.res +205 -0
  78. package/src/TestIndexerProxyStorage.res.mjs +151 -0
  79. package/src/TopicFilter.res +1 -1
  80. package/src/Types.ts +1 -1
  81. package/src/UserContext.res +424 -0
  82. package/src/UserContext.res.mjs +279 -0
  83. package/src/Utils.res +97 -26
  84. package/src/Utils.res.mjs +91 -44
  85. package/src/bindings/BigInt.res +10 -0
  86. package/src/bindings/BigInt.res.mjs +15 -0
  87. package/src/bindings/ClickHouse.res +120 -23
  88. package/src/bindings/ClickHouse.res.mjs +118 -28
  89. package/src/bindings/DateFns.res +74 -0
  90. package/src/bindings/DateFns.res.mjs +22 -0
  91. package/src/bindings/EventSource.res +8 -1
  92. package/src/bindings/EventSource.res.mjs +8 -1
  93. package/src/bindings/Express.res +1 -0
  94. package/src/bindings/Hrtime.res +14 -1
  95. package/src/bindings/Hrtime.res.mjs +22 -2
  96. package/src/bindings/Hrtime.resi +4 -0
  97. package/src/bindings/Lodash.res +0 -1
  98. package/src/bindings/NodeJs.res +49 -3
  99. package/src/bindings/NodeJs.res.mjs +11 -3
  100. package/src/bindings/Pino.res +24 -10
  101. package/src/bindings/Pino.res.mjs +14 -8
  102. package/src/bindings/Postgres.gen.ts +8 -0
  103. package/src/bindings/Postgres.res +5 -1
  104. package/src/bindings/Postgres.res.d.mts +5 -0
  105. package/src/bindings/PromClient.res +0 -10
  106. package/src/bindings/PromClient.res.mjs +0 -3
  107. package/src/bindings/Vitest.res +142 -0
  108. package/src/bindings/Vitest.res.mjs +9 -0
  109. package/src/bindings/WebSocket.res +27 -0
  110. package/src/bindings/WebSocket.res.mjs +2 -0
  111. package/src/bindings/Yargs.res +8 -0
  112. package/src/bindings/Yargs.res.mjs +2 -0
  113. package/src/db/EntityHistory.res +7 -7
  114. package/src/db/EntityHistory.res.mjs +9 -9
  115. package/src/db/InternalTable.res +59 -111
  116. package/src/db/InternalTable.res.mjs +73 -104
  117. package/src/db/Table.res +27 -8
  118. package/src/db/Table.res.mjs +25 -14
  119. package/src/sources/Evm.res +84 -0
  120. package/src/sources/Evm.res.mjs +105 -0
  121. package/src/sources/EvmChain.res +94 -0
  122. package/src/sources/EvmChain.res.mjs +60 -0
  123. package/src/sources/Fuel.res +19 -34
  124. package/src/sources/Fuel.res.mjs +34 -16
  125. package/src/sources/FuelSDK.res +38 -0
  126. package/src/sources/FuelSDK.res.mjs +29 -0
  127. package/src/sources/HyperFuel.res +2 -2
  128. package/src/sources/HyperFuel.resi +1 -1
  129. package/src/sources/HyperFuelClient.res +2 -2
  130. package/src/sources/HyperFuelSource.res +33 -13
  131. package/src/sources/HyperFuelSource.res.mjs +24 -16
  132. package/src/sources/HyperSync.res +36 -6
  133. package/src/sources/HyperSync.res.mjs +9 -7
  134. package/src/sources/HyperSync.resi +4 -0
  135. package/src/sources/HyperSyncClient.res +1 -1
  136. package/src/sources/HyperSyncHeightStream.res +47 -116
  137. package/src/sources/HyperSyncHeightStream.res.mjs +46 -73
  138. package/src/sources/HyperSyncSource.res +118 -139
  139. package/src/sources/HyperSyncSource.res.mjs +104 -121
  140. package/src/sources/Rpc.res +86 -14
  141. package/src/sources/Rpc.res.mjs +101 -9
  142. package/src/sources/RpcSource.res +621 -364
  143. package/src/sources/RpcSource.res.mjs +843 -410
  144. package/src/sources/RpcWebSocketHeightStream.res +181 -0
  145. package/src/sources/RpcWebSocketHeightStream.res.mjs +196 -0
  146. package/src/sources/Source.res +7 -5
  147. package/src/sources/SourceManager.res +325 -225
  148. package/src/sources/SourceManager.res.mjs +314 -171
  149. package/src/sources/SourceManager.resi +17 -6
  150. package/src/sources/Svm.res +81 -0
  151. package/src/sources/Svm.res.mjs +90 -0
  152. package/src/tui/Tui.res +247 -0
  153. package/src/tui/Tui.res.mjs +337 -0
  154. package/src/tui/bindings/Ink.res +371 -0
  155. package/src/tui/bindings/Ink.res.mjs +72 -0
  156. package/src/tui/bindings/Style.res +123 -0
  157. package/src/tui/bindings/Style.res.mjs +2 -0
  158. package/src/tui/components/BufferedProgressBar.res +40 -0
  159. package/src/tui/components/BufferedProgressBar.res.mjs +57 -0
  160. package/src/tui/components/CustomHooks.res +122 -0
  161. package/src/tui/components/CustomHooks.res.mjs +179 -0
  162. package/src/tui/components/Messages.res +41 -0
  163. package/src/tui/components/Messages.res.mjs +75 -0
  164. package/src/tui/components/SyncETA.res +174 -0
  165. package/src/tui/components/SyncETA.res.mjs +263 -0
  166. package/src/tui/components/TuiData.res +47 -0
  167. package/src/tui/components/TuiData.res.mjs +34 -0
  168. package/svm.schema.json +112 -0
  169. package/bin.js +0 -48
  170. package/src/EventRegister.res +0 -241
  171. package/src/EventRegister.res.mjs +0 -240
  172. package/src/bindings/Ethers.gen.ts +0 -14
  173. package/src/bindings/Ethers.res +0 -204
  174. package/src/bindings/Ethers.res.mjs +0 -130
  175. /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,134 +204,245 @@ 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,
166
232
  ~status: ref<status>,
167
233
  ~logger,
168
234
  ) => {
169
- let newHeight = ref(0)
235
+ let source = sourceState.source
236
+ let initialHeight = sourceState.knownHeight
237
+ let newHeight = ref(initialHeight)
170
238
  let retry = ref(0)
171
239
 
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,
240
+ while newHeight.contents <= knownHeight && status.contents !== Done {
241
+ // If subscription exists, wait for next height event
242
+ switch sourceState.unsubscribe {
243
+ | Some(_) =>
244
+ let height = await Promise.make((resolve, _reject) => {
245
+ sourceState.pendingHeightResolvers->Array.push(resolve)
178
246
  })
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
247
+
248
+ // Only accept heights greater than initialHeight
249
+ if height > initialHeight {
250
+ newHeight := height
251
+ }
252
+ | None =>
253
+ // No subscription, use REST polling
254
+ try {
255
+ let height = await source.getHeightOrThrow()
256
+
257
+ newHeight := height
258
+ if height <= knownHeight {
259
+ retry := 0
260
+
261
+ // If createHeightSubscription is available and height hasn't changed,
262
+ // create subscription instead of polling
263
+ switch source.createHeightSubscription {
264
+ | Some(createSubscription) =>
265
+ let unsubscribe = createSubscription(~onHeight=newHeight => {
266
+ sourceState.knownHeight = newHeight
267
+ // Resolve all pending height resolvers
268
+ let resolvers = sourceState.pendingHeightResolvers
269
+ sourceState.pendingHeightResolvers = []
270
+ resolvers->Array.forEach(resolve => resolve(newHeight))
271
+ })
272
+ sourceState.unsubscribe = Some(unsubscribe)
273
+ | None =>
274
+ // Slowdown polling when the chain isn't progressing
275
+ let pollingInterval = if status.contents === Stalled {
276
+ sourceManager.stalledPollingInterval
277
+ } else {
278
+ source.pollingInterval
279
+ }
280
+ await Utils.delay(pollingInterval)
281
+ }
190
282
  }
191
- await Utils.delay(pollingInterval)
283
+ } catch {
284
+ | exn =>
285
+ let retryInterval = sourceManager.getHeightRetryInterval(~retry=retry.contents)
286
+ logger->Logging.childTrace({
287
+ "msg": `Height retrieval from ${source.name} source failed. Retrying in ${retryInterval->Int.toString}ms.`,
288
+ "source": source.name,
289
+ "err": exn->Utils.prettifyExn,
290
+ })
291
+ retry := retry.contents + 1
292
+ await Utils.delay(retryInterval)
192
293
  }
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,
200
- })
201
- retry := retry.contents + 1
202
- await Utils.delay(retryInterval)
203
294
  }
204
295
  }
205
- Prometheus.SourceHeight.set(
206
- ~sourceName=source.name,
207
- ~chainId=source.chain->ChainMap.Chain.toChainId,
208
- ~blockNumber=newHeight.contents,
209
- )
296
+
297
+ // Update Prometheus only if height increased
298
+ if newHeight.contents > initialHeight {
299
+ Prometheus.SourceHeight.set(
300
+ ~sourceName=source.name,
301
+ ~chainId=source.chain->ChainMap.Chain.toChainId,
302
+ ~blockNumber=newHeight.contents,
303
+ )
304
+ }
305
+
210
306
  newHeight.contents
211
307
  }
212
308
 
309
+ let compareByOldestFailure = (a: sourceState, b: sourceState) =>
310
+ switch (a.lastFailedAt, b.lastFailedAt) {
311
+ | (None, Some(_)) => -1
312
+ | (Some(_), None) => 1
313
+ | (Some(a), Some(b)) => a < b ? -1 : a > b ? 1 : 0
314
+ | (None, None) => 0
315
+ }
316
+
317
+ // Priority: working primaries > working secondaries > all primaries.
318
+ let getNextSources = (sourceManager, ~isLive, ~excludedSources=?) => {
319
+ let now = Js.Date.now()
320
+ let workingPrimarySources = []
321
+ let allPrimarySources = []
322
+ let workingSecondarySources = []
323
+ for i in 0 to sourceManager.sourcesState->Array.length - 1 {
324
+ let sourceState = sourceManager.sourcesState->Array.getUnsafe(i)
325
+ if !sourceState.disabled {
326
+ let isExcluded = switch excludedSources {
327
+ | Some(set) => set->Utils.Set.has(sourceState)
328
+ | None => false
329
+ }
330
+ if !isExcluded {
331
+ let isWorking = switch sourceState.lastFailedAt {
332
+ | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
333
+ | None => true
334
+ }
335
+ switch getSourceRole(
336
+ ~sourceFor=sourceState.source.sourceFor,
337
+ ~isLive,
338
+ ~hasLive=sourceManager.hasLive,
339
+ ) {
340
+ | Some(Primary) =>
341
+ allPrimarySources->Array.push(sourceState)
342
+ if isWorking {
343
+ workingPrimarySources->Array.push(sourceState)
344
+ }
345
+ | Some(Secondary) if isWorking => workingSecondarySources->Array.push(sourceState)
346
+ | _ => ()
347
+ }
348
+ }
349
+ }
350
+ }
351
+ if workingPrimarySources->Array.length > 0 {
352
+ workingPrimarySources
353
+ } else if workingSecondarySources->Array.length > 0 {
354
+ workingSecondarySources
355
+ } else {
356
+ // All primaries in recovery — sort by oldest lastFailedAt (closest to recovery first)
357
+ allPrimarySources->Js.Array2.sortInPlaceWith(compareByOldestFailure)
358
+ }
359
+ }
360
+
361
+ // Single source selection from getNextSources.
362
+ // Prefers activeSource if it's in the candidates. Fast path: check first item.
363
+ let getNextSource = (sourceManager, ~isLive, ~excludedSources=?) => {
364
+ let sources = sourceManager->getNextSources(~isLive, ~excludedSources?)
365
+ switch sources->Array.get(0) {
366
+ | None => None
367
+ | Some(first) if first.source === sourceManager.activeSource => Some(first)
368
+ | _ =>
369
+ switch sources->Js.Array2.find(s => s.source === sourceManager.activeSource) {
370
+ | Some(_) as result => result
371
+ | None => sources->Array.get(0)
372
+ }
373
+ }
374
+ }
375
+
213
376
  // 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
377
+ let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isLive) => {
378
+ let {sourcesState} = sourceManager
216
379
 
217
380
  let logger = Logging.createChild(
218
381
  ~params={
219
382
  "chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId,
220
- "currentBlockHeight": currentBlockHeight,
383
+ "knownHeight": knownHeight,
221
384
  },
222
385
  )
223
386
  logger->Logging.childTrace("Initiating check for new blocks.")
224
387
 
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
- })
388
+ let mainSources = sourceManager->getNextSources(~isLive)
249
389
 
250
390
  let status = ref(Active)
251
391
 
392
+ let stallTimeout = if isLive {
393
+ sourceManager.newBlockStallTimeoutLive
394
+ } else {
395
+ sourceManager.newBlockStallTimeout
396
+ }
397
+
252
398
  let (source, newBlockHeight) = await Promise.race(
253
- syncSources
254
- ->Array.map(async source => {
399
+ mainSources
400
+ ->Array.map(async sourceState => {
255
401
  (
256
- source,
257
- await sourceManager->getSourceNewHeight(~source, ~currentBlockHeight, ~status, ~logger),
402
+ sourceState.source,
403
+ await sourceManager->getSourceNewHeight(~sourceState, ~knownHeight, ~status, ~logger),
258
404
  )
259
405
  })
260
406
  ->Array.concat([
261
- Utils.delay(sourceManager.newBlockFallbackStallTimeout)->Promise.then(() => {
407
+ Utils.delay(stallTimeout)->Promise.then(() => {
408
+ // Build fallback: sources not in mainSources with a valid role, even with recent lastFailedAt
409
+ let fallbackSources = []
410
+ sourcesState->Array.forEach(sourceState => {
411
+ if (
412
+ !(mainSources->Js.Array2.includes(sourceState)) &&
413
+ getSourceRole(
414
+ ~sourceFor=sourceState.source.sourceFor,
415
+ ~isLive,
416
+ ~hasLive=sourceManager.hasLive,
417
+ )->Option.isSome
418
+ ) {
419
+ fallbackSources->Array.push(sourceState)
420
+ }
421
+ })
422
+
262
423
  if status.contents !== Done {
263
424
  status := Stalled
264
425
 
265
426
  switch fallbackSources {
266
427
  | [] =>
267
428
  logger->Logging.childWarn(
268
- `No new blocks detected within ${(sourceManager.newBlockFallbackStallTimeout / 1000)
429
+ `No new blocks detected within ${(stallTimeout / 1000)
269
430
  ->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
431
  )
271
432
  | _ =>
272
433
  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.`,
434
+ `No new blocks detected within ${(stallTimeout / 1000)
435
+ ->Int.toString}s. Continuing polling with secondary RPC sources from the configuration.`,
275
436
  )
276
437
  }
277
438
  }
278
439
  // Promise.race will be forever pending if fallbackSources is empty
279
440
  // which is good for this use case
280
441
  Promise.race(
281
- fallbackSources->Array.map(async source => {
442
+ fallbackSources->Array.map(async sourceState => {
282
443
  (
283
- source,
284
- await sourceManager->getSourceNewHeight(
285
- ~source,
286
- ~currentBlockHeight,
287
- ~status,
288
- ~logger,
289
- ),
444
+ sourceState.source,
445
+ await sourceManager->getSourceNewHeight(~sourceState, ~knownHeight, ~status, ~logger),
290
446
  )
291
447
  }),
292
448
  )
@@ -296,7 +452,7 @@ let waitForNewBlock = async (sourceManager: t, ~currentBlockHeight) => {
296
452
 
297
453
  sourceManager.activeSource = source
298
454
 
299
- // Show a higher level log if we displayed a warning/error after newBlockFallbackStallTimeout
455
+ // Show a higher level log if we displayed a warning/error after newBlockStallTimeout
300
456
  let log = status.contents === Stalled ? Logging.childInfo : Logging.childTrace
301
457
  logger->log({
302
458
  "msg": `New blocks successfully found.`,
@@ -309,63 +465,43 @@ let waitForNewBlock = async (sourceManager: t, ~currentBlockHeight) => {
309
465
  newBlockHeight
310
466
  }
311
467
 
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
- })
468
+ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~knownHeight, ~isLive) => {
469
+ 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
470
 
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
- }
471
+ // Sources where the query is impossible — lazily allocated, excluded for the duration of this query
472
+ let excludedSourcesRef = ref(None)
351
473
 
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
- )
474
+ let toBlockRef = ref(query.toBlock)
361
475
  let responseRef = ref(None)
362
476
  let retryRef = ref(0)
363
- let initialSource = sourceManager.activeSource
364
- let sourceRef = ref(initialSource)
365
- let shouldUpdateActiveSource = ref(false)
366
477
 
367
478
  while responseRef.contents->Option.isNone {
368
- let source = sourceRef.contents
479
+ // Select the best source at the start of every iteration
480
+ let sourceState = switch sourceManager->getNextSource(
481
+ ~isLive,
482
+ ~excludedSources=?excludedSourcesRef.contents,
483
+ ) {
484
+ | Some(s) =>
485
+ if s.source !== sourceManager.activeSource {
486
+ let logger = Logging.createChild(
487
+ ~params={"chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId},
488
+ )
489
+ logger->Logging.childInfo({
490
+ "msg": "Switching data-source",
491
+ "source": s.source.name,
492
+ "previousSource": sourceManager.activeSource.name,
493
+ "fromBlock": query.fromBlock,
494
+ })
495
+ }
496
+ s
497
+ | None =>
498
+ let logger = Logging.createChild(
499
+ ~params={"chainId": sourceManager.activeSource.chain->ChainMap.Chain.toChainId},
500
+ )
501
+ %raw(`null`)->ErrorHandling.mkLogAndRaise(~logger, ~msg=noSourcesError)
502
+ }
503
+ sourceManager.activeSource = sourceState.source
504
+ let source = sourceState.source
369
505
  let toBlock = toBlockRef.contents
370
506
  let retry = retryRef.contents
371
507
 
@@ -389,7 +525,7 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
389
525
  ~addressesByContractName=query.addressesByContractName,
390
526
  ~indexingContracts=query.indexingContracts,
391
527
  ~partitionId=query.partitionId,
392
- ~currentBlockHeight,
528
+ ~knownHeight,
393
529
  ~selection=query.selection,
394
530
  ~retry,
395
531
  ~logger,
@@ -400,21 +536,20 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
400
536
  "numEvents": response.parsedQueueItems->Array.length,
401
537
  "stats": response.stats,
402
538
  })
539
+ sourceState.lastFailedAt = None
403
540
  responseRef := Some(response)
404
541
  } catch {
405
542
  | Source.GetItemsError(error) =>
406
543
  switch error {
407
544
  | UnsupportedSelection(_)
408
545
  | 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)
546
+ // These errors are impossible to recover, so we disable the source
547
+ // so it's not attempted anymore
548
+ let notAlreadyDisabled = sourceManager->disableSource(sourceState)
414
549
 
415
550
  // In case there are multiple partitions
416
551
  // failing at the same time. Log only once
417
- if notAlreadyDeleted {
552
+ if notAlreadyDisabled {
418
553
  switch error {
419
554
  | UnsupportedSelection({message}) => logger->Logging.childError(message)
420
555
  | FailedGettingFieldSelection({exn, message, blockNumber, logIndex}) =>
@@ -428,20 +563,7 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
428
563
  }
429
564
  }
430
565
 
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
- }
566
+ retryRef := 0
445
567
  }
446
568
  | FailedGettingItems({attemptedToBlock, retry: WithSuggestedToBlock({toBlock})}) =>
447
569
  logger->Logging.childTrace({
@@ -452,58 +574,24 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
452
574
  toBlockRef := Some(toBlock)
453
575
  retryRef := 0
454
576
  | 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
577
+ // Don't set lastFailedAt — the source isn't broken, the query just can't work on it
578
+ let excludedSources = switch excludedSourcesRef.contents {
579
+ | Some(s) => s
580
+ | None =>
581
+ let s = Utils.Set.make()
582
+ excludedSourcesRef := Some(s)
583
+ s
584
+ }
585
+ excludedSources->Utils.Set.add(sourceState)->ignore
463
586
 
464
587
  logger->Logging.childWarn({
465
- "msg": message ++ (hasAnotherSource ? " - Attempting to another source" : ""),
588
+ "msg": message ++ " - Attempting another source",
466
589
  "toBlock": attemptedToBlock,
467
590
  "err": exn->Utils.prettifyExn,
468
591
  })
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
- }
592
+ retryRef := 0
480
593
 
481
594
  | 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
595
  // Start displaying warnings after 4 failures
508
596
  let log = retry >= 4 ? Logging.childWarn : Logging.childTrace
509
597
  logger->log({
@@ -514,14 +602,30 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
514
602
  "err": exn->Utils.prettifyExn,
515
603
  })
516
604
 
517
- let shouldSwitch = nextSource !== source
605
+ let shouldSwitch = switch retry {
606
+ // Don't attempt a switch on first two failures
607
+ | 0 | 1 => false
608
+ // Then try to switch every second failure
609
+ | _ => retry->mod(2) === 0
610
+ }
611
+
518
612
  if shouldSwitch {
519
- logger->Logging.childInfo({
520
- "msg": "Switching to another data-source",
521
- "source": nextSource.name,
522
- })
523
- sourceRef := nextSource
524
- shouldUpdateActiveSource := true
613
+ let now = Js.Date.now()
614
+ sourceState.lastFailedAt = Some(now)
615
+ // Check if there's a working (recovered) source to switch to immediately
616
+ let nextSource =
617
+ sourceManager->getNextSource(~isLive, ~excludedSources=?excludedSourcesRef.contents)
618
+ let hasWorkingAlternative = switch nextSource {
619
+ | Some(s) =>
620
+ switch s.lastFailedAt {
621
+ | None => true
622
+ | Some(failedAt) => now -. failedAt >= sourceManager.recoveryTimeout
623
+ }
624
+ | None => false
625
+ }
626
+ if !hasWorkingAlternative {
627
+ await Utils.delay(Pervasives.min(backoffMillis, 60_000))
628
+ }
525
629
  } else {
526
630
  await Utils.delay(Pervasives.min(backoffMillis, 60_000))
527
631
  }
@@ -533,9 +637,5 @@ let executeQuery = async (sourceManager: t, ~query: FetchState.query, ~currentBl
533
637
  }
534
638
  }
535
639
 
536
- if shouldUpdateActiveSource.contents {
537
- sourceManager.activeSource = sourceRef.contents
538
- }
539
-
540
640
  responseRef.contents->Option.getUnsafe
541
641
  }