envio 3.3.0-alpha.5 → 3.3.0-alpha.6

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.
@@ -74,6 +74,47 @@ let getKnownRawBlockWithBackoff = async (
74
74
  }
75
75
  result.contents->Option.getOrThrow
76
76
  }
77
+ // Pulls the provider error message out of either a parsed Rpc.JsonRpcError or
78
+ // the raw napi JsExn shape (`error.error.message`), so classifiers below don't
79
+ // each have to re-derive it.
80
+ let getErrorMessage = (exn: exn): option<string> =>
81
+ switch exn {
82
+ | Rpc.JsonRpcError({message}) => Some(message)
83
+ | JsExn(error) =>
84
+ try {
85
+ let message: string = (error->Obj.magic)["error"]["message"]
86
+ message->S.assertOrThrow(S.string)
87
+ Some(message)
88
+ } catch {
89
+ | _ => None
90
+ }
91
+ | _ => None
92
+ }
93
+
94
+ // Deterministic "the range returned too much data" errors that carry no numeric
95
+ // block-range suggestion (HyperRPC's 50k-log cap, response-size and result-count
96
+ // limits). They depend on log density, not on a fixed block window, so waiting
97
+ // never helps — the same range always re-trips the same cap. The reaction is to
98
+ // shrink the range and retry immediately, ratcheting the max range down.
99
+ let isResponseTooLargeError = {
100
+ let patterns = [
101
+ /more than \d+ logs/i, // HyperRPC: "More than 50000 logs returned"
102
+ /\d+ logs returned/i,
103
+ /too many logs/i,
104
+ /query returned more than \d+ results/i, // ZkEVM
105
+ /query exceeds max results/i, // LlamaRPC
106
+ /response size should not/i, // 1RPC
107
+ /(backend )?response too large/i, // Optimism
108
+ /logs matched by query exceeds limit/i, // Arbitrum
109
+ /block range is too wide/i, // Ankr
110
+ ]
111
+ (exn: exn) =>
112
+ switch exn->getErrorMessage {
113
+ | Some(message) => patterns->Array.some(re => re->RegExp.test(message))
114
+ | None => false
115
+ }
116
+ }
117
+
77
118
  let getSuggestedBlockIntervalFromExn = {
78
119
  // Unknown provider: "retry with the range 123-456"
79
120
  let suggestedRangeRegExp = /retry with the range (\d+)-(\d+)/
@@ -118,14 +159,6 @@ let getSuggestedBlockIntervalFromExn = {
118
159
  // when we send request with numeric block range instead of hex
119
160
  // Infura, ZkSync: "Try with this block range [0x123,0x456]"
120
161
 
121
- // Future handling needed for these providers that don't suggest ranges:
122
- // - Ankr: "block range is too wide"
123
- // - 1RPC: "response size should not greater than 10000000 bytes"
124
- // - ZkEVM: "query returned more than 10000 results"
125
- // - LlamaRPC: "query exceeds max results"
126
- // - Optimism: "backend response too large" or "Block range is too large"
127
- // - Arbitrum: "logs matched by query exceeds limit of 10000"
128
-
129
162
  let parseMessageForBlockRange = (message: string) => {
130
163
  // Helper to extract block range from regex match
131
164
  let extractBlockRange = (execResult, ~isMaxRange) =>
@@ -209,17 +242,9 @@ let getSuggestedBlockIntervalFromExn = {
209
242
  // Whether it's the max range that the provider allows
210
243
  bool,
211
244
  )> =>
212
- switch exn {
213
- | Rpc.JsonRpcError({message}) => parseMessageForBlockRange(message)
214
- | JsExn(error) =>
215
- try {
216
- let message: string = (error->Obj.magic)["error"]["message"]
217
- message->S.assertOrThrow(S.string)
218
- parseMessageForBlockRange(message)
219
- } catch {
220
- | _ => None
221
- }
222
- | _ => None
245
+ switch exn->getErrorMessage {
246
+ | Some(message) => parseMessageForBlockRange(message)
247
+ | None => None
223
248
  }
224
249
  }
225
250
 
@@ -230,11 +255,38 @@ type eventBatchQuery = {
230
255
 
231
256
  let maxSuggestedBlockIntervalKey = "max"
232
257
 
258
+ let getSourceMaxBlockInterval = (mutSuggestedBlockIntervals, ~intervalCeiling) =>
259
+ mutSuggestedBlockIntervals
260
+ ->Utils.Dict.dangerouslyGetNonOption(maxSuggestedBlockIntervalKey)
261
+ ->Option.getOr(intervalCeiling)
262
+
263
+ type logSelection = {
264
+ addresses: option<array<Address.t>>,
265
+ topicQuery: Rpc.GetLogs.topicQuery,
266
+ }
267
+
268
+ // A log can satisfy more than one selection when a single event's `where` is an
269
+ // OR of param groups, so dedup the fanned-out responses by (blockNumber,
270
+ // logIndex) — unique per chain — keeping the first occurrence.
271
+ let mergeAndDedupItems = (itemsPerSelection: array<array<EvmRpcClient.rpcEventItem>>) => {
272
+ let seen = Utils.Set.make()
273
+ let merged = []
274
+ itemsPerSelection->Array.forEach(items =>
275
+ items->Array.forEach((item: EvmRpcClient.rpcEventItem) => {
276
+ let key = `${item.log.blockNumber->Int.toString}-${item.log.logIndex->Int.toString}`
277
+ if seen->Utils.Set.has(key)->not {
278
+ seen->Utils.Set.add(key)->ignore
279
+ merged->Array.push(item)->ignore
280
+ }
281
+ })
282
+ )
283
+ merged
284
+ }
285
+
233
286
  let getNextPage = (
234
287
  ~fromBlock,
235
288
  ~toBlock,
236
- ~addresses,
237
- ~topicQuery: Rpc.GetLogs.topicQuery,
289
+ ~logSelections: array<logSelection>,
238
290
  ~loadBlock,
239
291
  ~syncConfig as sc: Config.sourceSync,
240
292
  ~rpcClient: EvmRpcClient.t,
@@ -252,93 +304,109 @@ let getNextPage = (
252
304
  )
253
305
 
254
306
  let latestFetchedBlockPromise = loadBlock(toBlock)
255
- Prometheus.SourceRequestCount.increment(~sourceName, ~chainId, ~method="eth_getLogs")
256
- let logsPromise = rpcClient.getLogs({
257
- fromBlock,
258
- toBlock,
259
- ?addresses,
260
- topics: topicQuery->Array.map(filter =>
261
- switch filter {
262
- | Rpc.GetLogs.Null => Nullable.null
263
- | Single(topic) => Nullable.make([topic])
264
- | Multiple(topics) => Nullable.make(topics)
307
+
308
+ let queryLogs = ({addresses, topicQuery}: logSelection) => {
309
+ Prometheus.SourceRequestCount.increment(~sourceName, ~chainId, ~method="eth_getLogs")
310
+ rpcClient.getLogs({
311
+ fromBlock,
312
+ toBlock,
313
+ ?addresses,
314
+ topics: topicQuery->Array.map(filter =>
315
+ switch filter {
316
+ | Rpc.GetLogs.Null => Nullable.null
317
+ | Single(topic) => Nullable.make([topic])
318
+ | Multiple(topics) => Nullable.make(topics)
319
+ }
320
+ ),
321
+ })
322
+ }
323
+
324
+ let logsPromise = switch logSelections {
325
+ | [] =>
326
+ latestFetchedBlockPromise->Promise.thenResolve((latestFetchedBlockInfo): eventBatchQuery => {
327
+ items: [],
328
+ latestFetchedBlockInfo,
329
+ })
330
+ // Fast path: a single selection needs no cross-request merge or dedup.
331
+ | [logSelection] =>
332
+ logSelection
333
+ ->queryLogs
334
+ ->Promise.then(async items => {
335
+ {
336
+ items,
337
+ latestFetchedBlockInfo: await latestFetchedBlockPromise,
265
338
  }
266
- ),
267
- })->Promise.then(async items => {
268
- {
269
- items,
270
- latestFetchedBlockInfo: await latestFetchedBlockPromise,
271
- }
272
- })
339
+ })
340
+ | _ =>
341
+ logSelections
342
+ ->Array.map(queryLogs)
343
+ ->Promise.all
344
+ ->Promise.then(async itemsPerSelection => {
345
+ {
346
+ items: itemsPerSelection->mergeAndDedupItems,
347
+ latestFetchedBlockInfo: await latestFetchedBlockPromise,
348
+ }
349
+ })
350
+ }
273
351
 
274
352
  [queryTimoutPromise, logsPromise]
275
353
  ->Promise.race
276
354
  ->Promise.catch(err => {
355
+ let executedBlockInterval = toBlock - fromBlock + 1
356
+ let shrunkBlockInterval =
357
+ Pervasives.max(1, (executedBlockInterval->Int.toFloat *. sc.backoffMultiplicative)->Float.toInt)
358
+
359
+ let throwFailedGettingItems = retry =>
360
+ throw(Source.GetItemsError(FailedGettingItems({exn: err, attemptedToBlock: toBlock, retry})))
361
+ let throwResize = interval =>
362
+ throwFailedGettingItems(WithSuggestedToBlock({toBlock: fromBlock + interval - 1}))
363
+
277
364
  switch getSuggestedBlockIntervalFromExn(err) {
278
- | Some((nextBlockIntervalTry, isMaxRange)) =>
279
- mutSuggestedBlockIntervals->Dict.set(
280
- isMaxRange ? maxSuggestedBlockIntervalKey : partitionId,
281
- nextBlockIntervalTry,
282
- )
283
- throw(
284
- Source.GetItemsError(
285
- FailedGettingItems({
286
- exn: err,
287
- attemptedToBlock: toBlock,
288
- retry: WithSuggestedToBlock({
289
- toBlock: fromBlock + nextBlockIntervalTry - 1,
290
- }),
291
- }),
292
- ),
365
+ // "limited to N blocks" — a structural cap on the whole source; only tighten.
366
+ | Some((interval, true)) =>
367
+ let capped = Pervasives.min(
368
+ mutSuggestedBlockIntervals->getSourceMaxBlockInterval(~intervalCeiling=sc.intervalCeiling),
369
+ interval,
293
370
  )
371
+ mutSuggestedBlockIntervals->Dict.set(maxSuggestedBlockIntervalKey, capped)
372
+ throwResize(capped)
373
+ // A one-off suggested range ("retry with range X-Y") — apply to this partition.
374
+ | Some((interval, false)) =>
375
+ mutSuggestedBlockIntervals->Dict.set(partitionId, interval)
376
+ throwResize(interval)
377
+ // Density cap with no suggested number (too many logs / response too large):
378
+ // shrink THIS partition and retry immediately (no wait); acceleration
379
+ // re-adapts on the next successful query. The interval>1 guard avoids a
380
+ // no-progress tight loop on a single over-cap block.
381
+ | None if executedBlockInterval > 1 && err->isResponseTooLargeError =>
382
+ mutSuggestedBlockIntervals->Dict.set(partitionId, shrunkBlockInterval)
383
+ throwResize(shrunkBlockInterval)
384
+ // Transient/unknown — shrink this partition and back off.
294
385
  | None =>
295
- let executedBlockInterval = toBlock - fromBlock + 1
296
- let nextBlockIntervalTry =
297
- (executedBlockInterval->Int.toFloat *. sc.backoffMultiplicative)->Float.toInt
298
- mutSuggestedBlockIntervals->Dict.set(partitionId, nextBlockIntervalTry)
299
- throw(
300
- Source.GetItemsError(
301
- Source.FailedGettingItems({
302
- exn: err,
303
- attemptedToBlock: toBlock,
304
- retry: WithBackoff({
305
- message: `Failed getting data for the block range. Will try smaller block range for the next attempt.`,
306
- backoffMillis: sc.backoffMillis,
307
- }),
308
- }),
309
- ),
386
+ mutSuggestedBlockIntervals->Dict.set(partitionId, shrunkBlockInterval)
387
+ throwFailedGettingItems(
388
+ WithBackoff({
389
+ message: `Failed getting data for the block range. Will try smaller block range for the next attempt.`,
390
+ backoffMillis: sc.backoffMillis,
391
+ }),
310
392
  )
311
393
  }
312
394
  })
313
395
  }
314
396
 
315
- type logSelection = {
316
- addresses: option<array<Address.t>>,
317
- topicQuery: Rpc.GetLogs.topicQuery,
318
- }
319
-
320
397
  type selectionConfig = {
321
- getLogSelectionOrThrow: (~addressesByContractName: dict<array<Address.t>>) => logSelection,
398
+ getLogSelectionsOrThrow: (
399
+ ~addressesByContractName: dict<array<Address.t>>,
400
+ ) => array<logSelection>,
322
401
  }
323
402
 
324
403
  let getSelectionConfig = (selection: FetchState.selection, ~chain) => {
325
- let staticTopicSelections = []
326
- let dynamicEventFilters = []
327
-
328
- selection.eventConfigs
329
- ->(Utils.magic: array<Internal.eventConfig> => array<Internal.evmEventConfig>)
330
- ->Array.forEach(({getEventFiltersOrThrow}) => {
331
- switch getEventFiltersOrThrow(chain) {
332
- | Static(s) => staticTopicSelections->Array.pushMany(s)->ignore
333
- | Dynamic(fn) => dynamicEventFilters->Array.push(fn)->ignore
334
- }
335
- })
404
+ let evmEventConfigs =
405
+ selection.eventConfigs->(
406
+ Utils.magic: array<Internal.eventConfig> => array<Internal.evmEventConfig>
407
+ )
336
408
 
337
- let getLogSelectionOrThrow = switch (
338
- staticTopicSelections->LogSelection.compressTopicSelections,
339
- dynamicEventFilters,
340
- ) {
341
- | ([], []) =>
409
+ if evmEventConfigs->Utils.Array.isEmpty {
342
410
  throw(
343
411
  Source.GetItemsError(
344
412
  UnsupportedSelection({
@@ -346,48 +414,110 @@ let getSelectionConfig = (selection: FetchState.selection, ~chain) => {
346
414
  }),
347
415
  ),
348
416
  )
349
- | ([topicSelection], []) => {
350
- let topicQuery = topicSelection->Rpc.GetLogs.mapTopicQuery
351
- (~addressesByContractName) => {
352
- addresses: switch addressesByContractName->FetchState.addressesByContractNameGetAll {
353
- | [] => None
354
- | addresses => Some(addresses)
355
- },
356
- topicQuery,
417
+ }
418
+
419
+ // eth_getLogs takes one address list and one topic selection per request, so
420
+ // fan out to one request per selection. Each address-bound event is grouped by
421
+ // its contract and later scoped to that contract's own addresses — pooling all
422
+ // contracts' addresses would let one contract's query fetch a sibling's logs,
423
+ // which route back by address and bypass the sibling's filter (routing never
424
+ // re-applies it). Pure-wildcard events carry no address constraint, so they're
425
+ // pooled and resolved once.
426
+ let noAddressTopicSelections = []
427
+ let staticByContract = Dict.make()
428
+ let dynamicByContract = Dict.make()
429
+ let dynamicWildcardByContract = Dict.make()
430
+ let contractNames = Utils.Set.make()
431
+
432
+ evmEventConfigs->Array.forEach(({
433
+ contractName,
434
+ isWildcard,
435
+ dependsOnAddresses,
436
+ getEventFiltersOrThrow,
437
+ }) => {
438
+ let eventFilters = getEventFiltersOrThrow(chain)
439
+ if dependsOnAddresses {
440
+ contractNames->Utils.Set.add(contractName)->ignore
441
+ switch eventFilters {
442
+ | Internal.Static(topicSelections) =>
443
+ staticByContract->Utils.Dict.pushMany(contractName, topicSelections)
444
+ | Dynamic(fn) =>
445
+ (isWildcard ? dynamicWildcardByContract : dynamicByContract)->Utils.Dict.push(
446
+ contractName,
447
+ fn,
448
+ )
357
449
  }
450
+ } else {
451
+ noAddressTopicSelections
452
+ ->Array.pushMany(
453
+ switch eventFilters {
454
+ | Static(s) => s
455
+ | Dynamic(fn) => fn([])
456
+ },
457
+ )
458
+ ->ignore
358
459
  }
359
- | ([], [dynamicEventFilter]) if selection.eventConfigs->Array.length === 1 =>
360
- let eventConfig = selection.eventConfigs->Utils.Array.firstUnsafe
460
+ })
361
461
 
362
- (~addressesByContractName) => {
363
- let addresses = addressesByContractName->FetchState.addressesByContractNameGetAll
364
- {
365
- addresses: eventConfig.isWildcard ? None : Some(addresses),
366
- topicQuery: switch dynamicEventFilter(addresses) {
367
- | [topicSelection] => topicSelection->Rpc.GetLogs.mapTopicQuery
368
- | _ =>
369
- throw(
370
- Source.GetItemsError(
371
- UnsupportedSelection({
372
- message: "RPC data-source currently doesn't support an array of event filters. Please, create a GitHub issue if it's a blocker for you.",
373
- }),
374
- ),
375
- )
376
- },
377
- }
462
+ // `compressTopicSelections` folds the filter-less events into a single topic0
463
+ // OR-set, keeping the common case at one request.
464
+ let toLogSelections = (~addresses, topicSelections): array<logSelection> =>
465
+ topicSelections
466
+ ->LogSelection.compressTopicSelections
467
+ ->Array.map(topicSelection => {
468
+ addresses,
469
+ topicQuery: topicSelection->Rpc.GetLogs.mapTopicQuery,
470
+ })
471
+
472
+ // Address-independent, so resolve once (the wildcard partition reuses this).
473
+ let noAddressLogSelections = toLogSelections(~addresses=None, noAddressTopicSelections)
474
+
475
+ let getLogSelectionsOrThrow = if contractNames->Utils.Set.size === 0 {
476
+ (~addressesByContractName as _) => noAddressLogSelections
477
+ } else {
478
+ (~addressesByContractName): array<logSelection> => {
479
+ let logSelections = noAddressLogSelections->Array.copy
480
+ contractNames->Utils.Set.forEach(contractName => {
481
+ switch addressesByContractName->Utils.Dict.dangerouslyGetNonOption(contractName) {
482
+ | None
483
+ | Some([]) => ()
484
+ | Some(addresses) =>
485
+ // Static + dynamic non-wildcard filters, scoped to this contract's addresses.
486
+ let addressedTopicSelections = []
487
+ switch staticByContract->Utils.Dict.dangerouslyGetNonOption(contractName) {
488
+ | Some(s) => addressedTopicSelections->Array.pushMany(s)->ignore
489
+ | None => ()
490
+ }
491
+ switch dynamicByContract->Utils.Dict.dangerouslyGetNonOption(contractName) {
492
+ | Some(fns) =>
493
+ fns->Array.forEach(fn =>
494
+ addressedTopicSelections->Array.pushMany(fn(addresses))->ignore
495
+ )
496
+ | None => ()
497
+ }
498
+ logSelections
499
+ ->Array.pushMany(toLogSelections(~addresses=Some(addresses), addressedTopicSelections))
500
+ ->ignore
501
+
502
+ // Dynamic wildcard-by-address filters fold the address into the topics,
503
+ // so they still match any address.
504
+ switch dynamicWildcardByContract->Utils.Dict.dangerouslyGetNonOption(contractName) {
505
+ | Some(fns) =>
506
+ logSelections
507
+ ->Array.pushMany(
508
+ toLogSelections(~addresses=None, fns->Array.flatMap(fn => fn(addresses))),
509
+ )
510
+ ->ignore
511
+ | None => ()
512
+ }
513
+ }
514
+ })
515
+ logSelections
378
516
  }
379
- | _ =>
380
- throw(
381
- Source.GetItemsError(
382
- UnsupportedSelection({
383
- message: "RPC data-source currently supports event filters only when there's a single wildcard event. Please, create a GitHub issue if it's a blocker for you.",
384
- }),
385
- ),
386
- )
387
517
  }
388
518
 
389
519
  {
390
- getLogSelectionOrThrow: getLogSelectionOrThrow,
520
+ getLogSelectionsOrThrow: getLogSelectionsOrThrow,
391
521
  }
392
522
  }
393
523
 
@@ -886,6 +1016,10 @@ let make = (
886
1016
 
887
1017
  let getSelectionConfig = memoGetSelectionConfig(~chain)
888
1018
 
1019
+ // Per-partition adaptive block interval (AIMD), keyed by partitionId. The
1020
+ // `max` key holds a source-wide ceiling that only ever tightens, set by
1021
+ // structural provider limits ("limited to N blocks"). A partition's own entry
1022
+ // can go stale when partitions merge/split — acceptable, it re-adapts.
889
1023
  let mutSuggestedBlockIntervals = Dict.make()
890
1024
 
891
1025
  let client = Rpc.makeClient(url, ~headers?)
@@ -1023,15 +1157,14 @@ let make = (
1023
1157
  ) => {
1024
1158
  let startFetchingBatchTimeRef = Performance.now()
1025
1159
 
1026
- let suggestedBlockInterval = switch mutSuggestedBlockIntervals->Utils.Dict.dangerouslyGetNonOption(
1027
- maxSuggestedBlockIntervalKey,
1028
- ) {
1029
- | Some(maxSuggestedBlockInterval) => maxSuggestedBlockInterval
1030
- | None =>
1160
+ let sourceMaxBlockInterval =
1161
+ mutSuggestedBlockIntervals->getSourceMaxBlockInterval(~intervalCeiling=syncConfig.intervalCeiling)
1162
+ let suggestedBlockInterval = Pervasives.min(
1031
1163
  mutSuggestedBlockIntervals
1032
1164
  ->Utils.Dict.dangerouslyGetNonOption(partitionId)
1033
- ->Option.getOr(syncConfig.initialBlockInterval)
1034
- }
1165
+ ->Option.getOr(syncConfig.initialBlockInterval),
1166
+ sourceMaxBlockInterval,
1167
+ )
1035
1168
 
1036
1169
  // Always have a toBlock for an RPC worker
1037
1170
  let toBlock = switch toBlock {
@@ -1050,14 +1183,13 @@ let make = (
1050
1183
  ->Promise.thenResolve(json => Some(parseBlockInfo(json)))
1051
1184
  : Promise.resolve(None)
1052
1185
 
1053
- let {getLogSelectionOrThrow} = getSelectionConfig(selection)
1054
- let {addresses, topicQuery} = getLogSelectionOrThrow(~addressesByContractName)
1186
+ let {getLogSelectionsOrThrow} = getSelectionConfig(selection)
1187
+ let logSelections = getLogSelectionsOrThrow(~addressesByContractName)
1055
1188
 
1056
1189
  let {items, latestFetchedBlockInfo} = await getNextPage(
1057
1190
  ~fromBlock,
1058
1191
  ~toBlock=suggestedToBlock,
1059
- ~addresses,
1060
- ~topicQuery,
1192
+ ~logSelections,
1061
1193
  ~loadBlock=blockNumber =>
1062
1194
  blockLoader.contents
1063
1195
  ->LazyLoader.get(blockNumber)
@@ -1072,20 +1204,16 @@ let make = (
1072
1204
 
1073
1205
  let executedBlockInterval = suggestedToBlock - fromBlock + 1
1074
1206
 
1075
- // Increase the suggested block interval only when it was actually applied
1076
- // and we didn't query to a hard toBlock
1077
- // We also don't care about it when we have a hard max block interval
1078
- if (
1079
- executedBlockInterval >= suggestedBlockInterval &&
1080
- !(mutSuggestedBlockIntervals->Dict.has(maxSuggestedBlockIntervalKey))
1081
- ) {
1082
- // Increase batch size going forward, but do not increase past a configured maximum
1083
- // See: https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
1207
+ // Grow this partition's interval only when the full suggested range was
1208
+ // actually applied (not clamped by a hard toBlock). The min clamps to the
1209
+ // source-wide ceiling, which also stops growth once a structural cap tightened it.
1210
+ // See: https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
1211
+ if executedBlockInterval >= suggestedBlockInterval {
1084
1212
  mutSuggestedBlockIntervals->Dict.set(
1085
1213
  partitionId,
1086
1214
  Pervasives.min(
1087
1215
  executedBlockInterval + syncConfig.accelerationAdditive,
1088
- syncConfig.intervalCeiling,
1216
+ sourceMaxBlockInterval,
1089
1217
  ),
1090
1218
  )
1091
1219
  }