envio 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/evm.schema.json +26 -0
  2. package/fuel.schema.json +26 -0
  3. package/index.d.ts +35 -3
  4. package/package.json +6 -7
  5. package/src/Batch.res +9 -0
  6. package/src/ChainState.res +35 -39
  7. package/src/ChainState.res.mjs +14 -11
  8. package/src/ChainState.resi +15 -7
  9. package/src/Config.res +52 -44
  10. package/src/Config.res.mjs +51 -17
  11. package/src/Core.res +4 -0
  12. package/src/CrossChainState.res +15 -23
  13. package/src/CrossChainState.res.mjs +10 -18
  14. package/src/CrossChainState.resi +4 -1
  15. package/src/Ecosystem.res +7 -2
  16. package/src/Envio.res +6 -4
  17. package/src/EventConfigBuilder.res +169 -26
  18. package/src/EventConfigBuilder.res.mjs +124 -15
  19. package/src/HandlerRegister.res +21 -4
  20. package/src/HandlerRegister.res.mjs +24 -5
  21. package/src/InMemoryStore.res +9 -7
  22. package/src/InMemoryStore.res.mjs +3 -3
  23. package/src/IndexerState.res +43 -6
  24. package/src/IndexerState.res.mjs +36 -7
  25. package/src/IndexerState.resi +4 -4
  26. package/src/Internal.res +16 -17
  27. package/src/Metrics.res +72 -6
  28. package/src/Metrics.res.mjs +30 -2
  29. package/src/Persistence.res +36 -30
  30. package/src/Persistence.res.mjs +1 -20
  31. package/src/PgStorage.res +104 -56
  32. package/src/PgStorage.res.mjs +57 -35
  33. package/src/PruneStaleHistory.res +85 -52
  34. package/src/PruneStaleHistory.res.mjs +63 -37
  35. package/src/Rollback.res +68 -45
  36. package/src/Rollback.res.mjs +38 -19
  37. package/src/RollbackCommit.res +7 -9
  38. package/src/RollbackCommit.res.mjs +5 -6
  39. package/src/SafeCheckpointTracking.res +11 -11
  40. package/src/SafeCheckpointTracking.res.mjs +7 -6
  41. package/src/SimulateItems.res +33 -18
  42. package/src/SimulateItems.res.mjs +26 -22
  43. package/src/Sink.res +38 -26
  44. package/src/Sink.res.mjs +21 -16
  45. package/src/TestIndexer.res +5 -5
  46. package/src/TestIndexer.res.mjs +1 -1
  47. package/src/Utils.res +170 -0
  48. package/src/Utils.res.mjs +153 -0
  49. package/src/Writing.res +2 -2
  50. package/src/Writing.res.mjs +1 -1
  51. package/src/bindings/ClickHouse.res +366 -698
  52. package/src/bindings/ClickHouse.res.mjs +371 -421
  53. package/src/bindings/ClickHouseSink.res +337 -0
  54. package/src/bindings/ClickHouseSink.res.mjs +246 -0
  55. package/src/bindings/EventSource.res +8 -2
  56. package/src/bindings/NodeJs.res +6 -0
  57. package/src/bindings/Vitest.res +23 -0
  58. package/src/bindings/Vitest.res.mjs +3 -0
  59. package/src/bindings/WebSocket.res +9 -10
  60. package/src/db/CheckpointBounds.res +53 -0
  61. package/src/db/CheckpointBounds.res.mjs +44 -0
  62. package/src/db/EntityFilter.res +36 -17
  63. package/src/db/EntityFilter.res.mjs +25 -14
  64. package/src/db/EntityHistory.res +47 -22
  65. package/src/db/EntityHistory.res.mjs +19 -12
  66. package/src/db/InternalTable.res +84 -35
  67. package/src/db/InternalTable.res.mjs +56 -23
  68. package/src/db/RollbackFloors.res +55 -0
  69. package/src/db/RollbackFloors.res.mjs +92 -0
  70. package/src/db/Table.res +22 -4
  71. package/src/db/Table.res.mjs +30 -9
  72. package/src/sources/Evm.res +2 -0
  73. package/src/sources/Evm.res.mjs +2 -0
  74. package/src/sources/EvmHyperSyncSource.res +3 -3
  75. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  76. package/src/sources/EvmRpcWs.res +124 -0
  77. package/src/sources/EvmRpcWs.res.mjs +117 -0
  78. package/src/sources/Fuel.res +2 -0
  79. package/src/sources/Fuel.res.mjs +2 -0
  80. package/src/sources/FuelHyperSyncSource.res +1 -1
  81. package/src/sources/FuelHyperSyncSource.res.mjs +2 -1
  82. package/src/sources/HeightFeed.res +568 -0
  83. package/src/sources/HeightFeed.res.mjs +452 -0
  84. package/src/sources/HeightStream.res +257 -0
  85. package/src/sources/HeightStream.res.mjs +194 -0
  86. package/src/sources/HyperSync.res +5 -0
  87. package/src/sources/HyperSync.res.mjs +3 -0
  88. package/src/sources/HyperSync.resi +4 -0
  89. package/src/sources/HyperSyncSSE.res +49 -0
  90. package/src/sources/HyperSyncSSE.res.mjs +79 -0
  91. package/src/sources/RpcSource.res +1 -1
  92. package/src/sources/RpcSource.res.mjs +2 -2
  93. package/src/sources/Source.res +54 -1
  94. package/src/sources/Source.res.mjs +25 -0
  95. package/src/sources/SourceManager.res +220 -227
  96. package/src/sources/SourceManager.res.mjs +139 -164
  97. package/src/sources/SourceManager.resi +8 -0
  98. package/src/sources/Svm.res +2 -0
  99. package/src/sources/Svm.res.mjs +2 -0
  100. package/src/sources/SvmHyperSyncClient.res +70 -127
  101. package/src/sources/SvmHyperSyncClient.res.mjs +51 -25
  102. package/src/sources/SvmHyperSyncSource.res +18 -15
  103. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  104. package/svm.schema.json +1 -75
  105. package/src/MemoryStorage.res +0 -729
  106. package/src/MemoryStorage.res.mjs +0 -586
  107. package/src/sources/HyperSyncHeightStream.res +0 -129
  108. package/src/sources/HyperSyncHeightStream.res.mjs +0 -110
  109. package/src/sources/RpcWebSocketHeightStream.res +0 -175
  110. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -180
@@ -7,9 +7,7 @@ type requestStatAgg = {mutable count: int, mutable seconds: float}
7
7
 
8
8
  type sourceState = {
9
9
  source: Source.t,
10
- mutable knownHeight: int,
11
- mutable unsubscribe: option<unit => unit>,
12
- mutable pendingHeightResolvers: array<int => unit>,
10
+ feed: HeightFeed.t,
13
11
  mutable disabled: bool,
14
12
  // Timestamp (ms) when this source last failed during executeQuery.
15
13
  // Used to decide when to attempt recovery to this source.
@@ -17,17 +15,23 @@ type sourceState = {
17
15
  requestStats: dict<requestStatAgg>,
18
16
  }
19
17
 
20
- let recordRequestStats = (sourceState: sourceState, requestStats: array<Source.requestStat>) => {
18
+ let recordStatsInto = (
19
+ aggregates: dict<requestStatAgg>,
20
+ requestStats: array<Source.requestStat>,
21
+ ) => {
21
22
  requestStats->Array.forEach(({method, seconds}) => {
22
- switch sourceState.requestStats->Utils.Dict.dangerouslyGetNonOption(method) {
23
+ switch aggregates->Utils.Dict.dangerouslyGetNonOption(method) {
23
24
  | Some(agg) =>
24
25
  agg.count = agg.count + 1
25
26
  agg.seconds = agg.seconds +. seconds
26
- | None => sourceState.requestStats->Dict.set(method, {count: 1, seconds})
27
+ | None => aggregates->Dict.set(method, {count: 1, seconds})
27
28
  }
28
29
  })
29
30
  }
30
31
 
32
+ let recordRequestStats = (sourceState: sourceState, requestStats: array<Source.requestStat>) =>
33
+ sourceState.requestStats->recordStatsInto(requestStats)
34
+
31
35
  // Flattened (source, method) aggregates for Metrics.renderSourceRequests to
32
36
  // inline into the /metrics response.
33
37
  type requestStatSample = {
@@ -53,7 +57,6 @@ type t = {
53
57
  newBlockStallTimeoutRealtime: int,
54
58
  stalledPollingInterval: int,
55
59
  reducedPollingInterval: int,
56
- getHeightRetryInterval: (~retry: int) => int,
57
60
  mutable activeSource: Source.t,
58
61
  mutable waitingForNewBlockStateId: option<int>,
59
62
  // Dedupes the "waiting for new blocks" trace so it fires once per contiguous
@@ -108,17 +111,45 @@ type sourceHeightSample = {
108
111
  let getSourceHeightSamples = (sourceManager: t): array<sourceHeightSample> => {
109
112
  let samples = []
110
113
  sourceManager.sourcesState->Array.forEach(sourceState => {
111
- if sourceState.knownHeight > 0 {
114
+ let knownHeight = sourceState.feed->HeightFeed.knownHeight
115
+ if knownHeight > 0 {
112
116
  samples->Array.push({
113
117
  sourceName: sourceState.source.name,
114
118
  chainId: sourceState.source.chainId,
115
- height: sourceState.knownHeight,
119
+ height: knownHeight,
116
120
  })
117
121
  }
118
122
  })
119
123
  samples
120
124
  }
121
125
 
126
+ // Per-source height subscription health for envio_source_height_stream_*.
127
+ // Every source a wait has asked for a stream is reported, including one whose
128
+ // stream has never come up — that is a stream nothing else would say anything
129
+ // about. A source that was never asked is skipped, so a chain that only ever
130
+ // polls renders none of it rather than sitting at zero on it.
131
+ type heightStreamSample = {
132
+ sourceName: string,
133
+ chainId: ChainId.t,
134
+ stream: HeightFeed.streamSample,
135
+ }
136
+
137
+ let getHeightStreamSamples = (sourceManager: t): array<heightStreamSample> => {
138
+ let samples = []
139
+ sourceManager.sourcesState->Array.forEach(sourceState =>
140
+ switch sourceState.feed->HeightFeed.sample {
141
+ | Some(stream) =>
142
+ samples->Array.push({
143
+ sourceName: sourceState.source.name,
144
+ chainId: sourceState.source.chainId,
145
+ stream,
146
+ })
147
+ | None => ()
148
+ }
149
+ )
150
+ samples
151
+ }
152
+
122
153
  // Each accessor adds the in-progress interval of the current status, so a
123
154
  // scrape during a long idle/wait/query isn't stale until the next transition.
124
155
  let idleSeconds = (sourceManager: t) =>
@@ -308,13 +339,18 @@ let make = (
308
339
  }
309
340
  {
310
341
  sourcesState: sources->Array.map(source => {
311
- source,
312
- knownHeight: 0,
313
- unsubscribe: None,
314
- pendingHeightResolvers: [],
315
- disabled: false,
316
- lastFailedAt: None,
317
- requestStats: Dict.make(),
342
+ let requestStats = Dict.make()
343
+ {
344
+ source,
345
+ feed: HeightFeed.make(
346
+ ~source,
347
+ ~recordRequestStats=stats => requestStats->recordStatsInto(stats),
348
+ ~getHeightRetryInterval,
349
+ ),
350
+ disabled: false,
351
+ lastFailedAt: None,
352
+ requestStats,
353
+ }
318
354
  }),
319
355
  activeSource: initialActiveSource,
320
356
  waitingForNewBlockStateId: None,
@@ -324,7 +360,6 @@ let make = (
324
360
  newBlockStallTimeoutRealtime,
325
361
  stalledPollingInterval,
326
362
  reducedPollingInterval,
327
- getHeightRetryInterval,
328
363
  recoveryTimeout,
329
364
  statusStart: Performance.now(),
330
365
  status: Idle,
@@ -406,15 +441,10 @@ let dispatch = async (
406
441
  }
407
442
  }
408
443
 
409
- type status = Active | Stalled | Done
410
-
411
444
  let disableSource = (sourceManager: t, sourceState: sourceState) => {
412
445
  if !sourceState.disabled {
413
446
  sourceState.disabled = true
414
- switch sourceState.unsubscribe {
415
- | Some(unsubscribe) => unsubscribe()
416
- | None => ()
417
- }
447
+ sourceState.feed->HeightFeed.stop
418
448
  if sourceState.source.sourceFor === Realtime {
419
449
  // Only clear hasRealtime if no other non-disabled Realtime sources remain
420
450
  let hasOtherRealtime =
@@ -429,123 +459,6 @@ let disableSource = (sourceManager: t, sourceState: sourceState) => {
429
459
  }
430
460
  }
431
461
 
432
- let getSourceNewHeight = async (
433
- sourceManager,
434
- ~sourceState: sourceState,
435
- ~knownHeight,
436
- ~stallTimeout,
437
- ~isRealtime,
438
- ~status: ref<status>,
439
- ~logger,
440
- ~reducedPolling,
441
- ) => {
442
- let source = sourceState.source
443
- let initialHeight = sourceState.knownHeight
444
- let newHeight = ref(initialHeight)
445
- let retry = ref(0)
446
-
447
- while newHeight.contents <= knownHeight && status.contents !== Done {
448
- switch sourceState.unsubscribe {
449
- | Some(_) =>
450
- let subscriptionPromise = Promise.make((resolve, _reject) => {
451
- sourceState.pendingHeightResolvers->Array.push(resolve)
452
- })
453
- // If the subscription goes quiet for half the stall timeout, fall back to REST
454
- // polling. Jitter the trigger across [stallTimeout/2, stallTimeout) so indexers
455
- // that go quiet together don't all start polling at the same instant.
456
- let half = stallTimeout / 2
457
- let pollingFallback = Utils.delay(
458
- half + (Math.random() *. half->Int.toFloat)->Float.toInt,
459
- )->Promise.then(async () => {
460
- logger->Logging.childTrace({
461
- "msg": "onHeight subscription stale, switching to polling fallback",
462
- "source": source.name,
463
- "chainId": source.chainId,
464
- })
465
- let h = ref(initialHeight)
466
- while h.contents <= knownHeight && !(newHeight.contents > initialHeight) {
467
- try {
468
- let res = await source.getHeightOrThrow()
469
- sourceState->recordRequestStats(res.requestStats)
470
- h := res.height
471
- } catch {
472
- | _ => ()
473
- }
474
- if h.contents <= knownHeight && !(newHeight.contents > initialHeight) {
475
- await Utils.delay(source.pollingInterval)
476
- }
477
- }
478
- h.contents
479
- })
480
- let height = await Promise.race([subscriptionPromise, pollingFallback])
481
-
482
- // Only accept heights greater than initialHeight
483
- if height > initialHeight {
484
- newHeight := height
485
- }
486
- | None =>
487
- // No subscription, use REST polling
488
- try {
489
- let res = await source.getHeightOrThrow()
490
- sourceState->recordRequestStats(res.requestStats)
491
- let height = res.height
492
-
493
- newHeight := height
494
- if height <= knownHeight {
495
- retry := 0
496
-
497
- // If createHeightSubscription is available and height hasn't changed,
498
- // create subscription instead of polling
499
- switch source.createHeightSubscription {
500
- | Some(createSubscription) if isRealtime =>
501
- let unsubscribe = createSubscription(~onHeight=newHeight => {
502
- // Ignore non-increasing heights. The height stream re-emits the current
503
- // head on every (re)connect; waking the wait loop on a height we already
504
- // know spins it and leaks fallback pollers (#1270).
505
- if newHeight > sourceState.knownHeight {
506
- sourceState->recordRequestStats([{Source.method: "heightPush", seconds: 0.}])
507
- sourceState.knownHeight = newHeight
508
- let resolvers = sourceState.pendingHeightResolvers
509
- sourceState.pendingHeightResolvers = []
510
- resolvers->Array.forEach(resolve => resolve(newHeight))
511
- } else {
512
- sourceState->recordRequestStats([{Source.method: "heightPushIgnored", seconds: 0.}])
513
- }
514
- })
515
- sourceState.unsubscribe = Some(unsubscribe)
516
- | _ =>
517
- // Slowdown polling when the chain isn't progressing
518
- let pollingInterval = if reducedPolling {
519
- sourceManager.reducedPollingInterval
520
- } else if status.contents === Stalled {
521
- sourceManager.stalledPollingInterval
522
- } else {
523
- source.pollingInterval
524
- }
525
- await Utils.delay(pollingInterval)
526
- }
527
- }
528
- } catch {
529
- | exn =>
530
- let retryInterval = sourceManager.getHeightRetryInterval(~retry=retry.contents)
531
- logger->Logging.childTrace({
532
- "msg": `Height retrieval from ${source.name} source failed. Retrying in ${retryInterval->Int.toString}ms.`,
533
- "source": source.name,
534
- "err": exn->Utils.prettifyExn,
535
- })
536
- retry := retry.contents + 1
537
- await Utils.delay(retryInterval)
538
- }
539
- }
540
- }
541
-
542
- if newHeight.contents > sourceState.knownHeight {
543
- sourceState.knownHeight = newHeight.contents
544
- }
545
-
546
- newHeight.contents
547
- }
548
-
549
462
  let compareByOldestFailure = (a: sourceState, b: sourceState) =>
550
463
  switch (a.lastFailedAt, b.lastFailedAt) {
551
464
  | (None, Some(_)) => Ordering.less
@@ -620,7 +533,7 @@ let maxRetryBackoffMillis = 60_000
620
533
  // 100ms doubling up to the cap. `backoffBeforeRetry` still applies the caller's
621
534
  // floor and the cap on top.
622
535
  let retryBackoffMillis = retry =>
623
- Pervasives.min(100. *. 2. ** retry->Int.toFloat, maxRetryBackoffMillis->Int.toFloat)->Float.toInt
536
+ Utils.expBackoff(~base=100, ~exp=retry, ~maxMillis=maxRetryBackoffMillis)
624
537
 
625
538
  // Floor for the retries driven by a condition the source reported itself
626
539
  // (behind the head, inconsistent response). Unlike a caller-supplied backoff of
@@ -754,7 +667,12 @@ let retryInconsistentResponse = async (
754
667
  }
755
668
 
756
669
  // Polls for a block height greater than the given block number to ensure a new block is available for indexing.
757
- let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isRealtime, ~reducedPolling) => {
670
+ /*
671
+ Resolves at the first height above `knownHeight` that any eligible source
672
+ reports. A waiter is registered per source and the first one to fire wins,
673
+ cancelling the rest.
674
+ */
675
+ let waitForNewBlock = (sourceManager: t, ~knownHeight, ~isRealtime, ~reducedPolling) => {
758
676
  let {sourcesState} = sourceManager
759
677
 
760
678
  let logger = Logging.createChild(
@@ -775,7 +693,9 @@ let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isRealtime, ~reduc
775
693
 
776
694
  let mainSources = sourceManager->getNextSources(~isRealtime)
777
695
 
778
- let status = ref(Active)
696
+ // Whether this wait has already run out its stall window. It only changes the
697
+ // cadence the sources poll at and the level the closing line is logged at.
698
+ let stalled = ref(false)
779
699
 
780
700
  // Use a much longer stall timeout when reduced polling is active
781
701
  // to avoid spurious stall warnings while waiting for other chains to backfill
@@ -787,94 +707,168 @@ let waitForNewBlock = async (sourceManager: t, ~knownHeight, ~isRealtime, ~reduc
787
707
  sourceManager.newBlockStallTimeout
788
708
  }
789
709
 
790
- let (source, newBlockHeight) = await Promise.race(
791
- mainSources
792
- ->Array.map(async sourceState => {
793
- (
794
- sourceState.source,
795
- await sourceManager->getSourceNewHeight(
796
- ~sourceState,
797
- ~knownHeight,
798
- ~stallTimeout,
799
- ~isRealtime,
800
- ~status,
801
- ~logger,
802
- ~reducedPolling,
803
- ),
804
- )
805
- })
806
- ->Array.concat([
807
- Utils.delay(stallTimeout)->Promise.then(() => {
808
- // Build fallback: non-disabled sources not in mainSources with a valid role, even with recent lastFailedAt
809
- let fallbackSources = []
810
- sourcesState->Array.forEach(sourceState => {
811
- if (
812
- !sourceState.disabled &&
813
- !(mainSources->Array.includes(sourceState)) &&
814
- getSourceRole(
815
- ~sourceFor=sourceState.source.sourceFor,
816
- ~isRealtime,
817
- ~hasRealtime=sourceManager.hasRealtime,
818
- )->Option.isSome
819
- ) {
820
- fallbackSources->Array.push(sourceState)
821
- }
710
+ Promise.make((resolve, _reject) => {
711
+ // Every waiter this wait holds, on primaries and on any fallback recruited
712
+ // later, so a stall reaches all of them.
713
+ let watched: array<HeightFeed.subscription> = []
714
+ let pokeTimeoutId = ref(None)
715
+ let stallTimeoutId = ref(None)
716
+
717
+ // Safe to repeat: unsubscribing twice removes a waiter that is already gone.
718
+ let cleanup = () => {
719
+ watched->Array.forEach(subscription => subscription.unsubscribe())
720
+ watched->Utils.Array.clearInPlace
721
+ pokeTimeoutId->Utils.clearTimeoutRef
722
+ stallTimeoutId->Utils.clearTimeoutRef
723
+ }
724
+
725
+ let settled = ref(false)
726
+ let settle = (source: Source.t, height) =>
727
+ if !settled.contents {
728
+ settled := true
729
+ // Before anything else: the sources still watching have no reason to
730
+ // keep polling for a height this wait already has.
731
+ cleanup()
732
+ sourceManager.activeSource = source
733
+ // Show a higher level log if we displayed a warning/error after newBlockStallTimeout
734
+ let log = stalled.contents ? Logging.childInfo : Logging.childTrace
735
+ logger->log({
736
+ "msg": `New blocks successfully found.`,
737
+ "source": source.name,
738
+ "newBlockHeight": height,
822
739
  })
740
+ sourceManager.waitingLogged = false
741
+ resolve(height)
742
+ }
823
743
 
824
- if status.contents !== Done {
825
- status := Stalled
826
-
827
- switch fallbackSources {
828
- | [] =>
829
- logger->Logging.childWarn(
830
- `No new blocks detected within ${(stallTimeout / 1000)
831
- ->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`,
832
- )
833
- | _ =>
834
- logger->Logging.childWarn(
835
- `No new blocks detected within ${(stallTimeout / 1000)
836
- ->Int.toString}s. Continuing polling with secondary RPC sources from the configuration.`,
837
- )
838
- }
744
+ // Registering can answer the wait on the spot, from a height the source
745
+ // already knew, so every site has to cope with the wait being over — either
746
+ // before it got here, or because of its own call. Handled once here rather
747
+ // than at each place that registers.
748
+ let watch = (sourceState: sourceState, ~withStream) =>
749
+ if !settled.contents {
750
+ if withStream {
751
+ // Lazy and explicit: a source that can push heights subscribes when a
752
+ // realtime wait starts wanting them, and not before. A stream outlives
753
+ // the wait that asked for it and nothing takes it back off, so only the
754
+ // sources this wait was built on ask for one — a source recruited
755
+ // because another stalled is here to poll, and would otherwise hold a
756
+ // connection open for the life of the process on the strength of one
757
+ // stall.
758
+ sourceState.feed->HeightFeed.enableStream
839
759
  }
840
- // Promise.race will be forever pending if fallbackSources is empty
841
- // which is good for this use case
842
- Promise.race(
843
- fallbackSources->Array.map(async sourceState => {
844
- (
845
- sourceState.source,
846
- await sourceManager->getSourceNewHeight(
847
- ~sourceState,
848
- ~knownHeight,
849
- ~stallTimeout,
850
- ~isRealtime,
851
- ~status,
852
- ~logger,
853
- ~reducedPolling,
854
- ),
855
- )
856
- }),
760
+ let subscription = sourceState.feed->HeightFeed.onHeightAbove(
761
+ ~knownHeight,
762
+ // Read per poll rather than captured, so a wait that goes on to stall
763
+ // slows its own polling down without anything having to restart it.
764
+ ~interval=() =>
765
+ if reducedPolling {
766
+ sourceManager.reducedPollingInterval
767
+ } else if stalled.contents {
768
+ sourceManager.stalledPollingInterval
769
+ } else {
770
+ sourceState.source.pollingInterval
771
+ },
772
+ ~onHeight=height => settle(sourceState.source, height),
857
773
  )
858
- }),
859
- ]),
860
- )
861
-
862
- sourceManager.activeSource = source
774
+ if settled.contents {
775
+ // This registration is what answered the wait, and the cleanup that ran
776
+ // inside it could not reach a subscription it had not returned yet.
777
+ subscription.unsubscribe()
778
+ } else {
779
+ watched->Array.push(subscription)->ignore
780
+ }
781
+ }
863
782
 
864
- // Show a higher level log if we displayed a warning/error after newBlockStallTimeout
865
- let log = status.contents === Stalled ? Logging.childInfo : Logging.childTrace
866
- logger->log({
867
- "msg": `New blocks successfully found.`,
868
- "source": source.name,
869
- "newBlockHeight": newBlockHeight,
870
- })
871
- sourceManager.waitingLogged = false
783
+ mainSources->Array.forEach(sourceState => sourceState->watch(~withStream=isRealtime))
784
+
785
+ if !settled.contents {
786
+ // Spread across the window, and re-spread on every repeat: every indexer on
787
+ // one provider goes quiet in the same instant that provider does, and
788
+ // putting them all on one schedule is how a quiet chain becomes a stampede.
789
+ // Re-armed because distrusting a stream is a one-shot — the next height it
790
+ // delivers takes it back at its word — and the silence that earned it can
791
+ // come straight back.
792
+ let rec armPokeTimeout = () =>
793
+ pokeTimeoutId := Some(setTimeout(() => {
794
+ pokeTimeoutId := None
795
+ if !settled.contents {
796
+ watched->Array.forEach(subscription => subscription.distrustStream())
797
+ }
872
798
 
873
- status := Done
799
+ // Re-checked: distrusting can answer the wait, and a timer armed
800
+ // after the cleanup that follows is one nothing can ever clear.
801
+ if !settled.contents {
802
+ armPokeTimeout()
803
+ }
804
+ }, Utils.jitter(stallTimeout)))
805
+
806
+ // Punctual, unlike the poke above: newBlockStallTimeout is a promise to the
807
+ // operator about when they hear about a quiet chain, and spreading that
808
+ // would report it early. Fires once — the warning is worth saying once per
809
+ // wait, and a fallback stays recruited.
810
+ let armStallTimeout = () =>
811
+ stallTimeoutId := Some(setTimeout(() => {
812
+ stallTimeoutId := None
813
+ if !settled.contents {
814
+ stalled := true
815
+
816
+ // Build fallback: non-disabled sources not in mainSources with a valid role, even with recent lastFailedAt
817
+ let fallbackSources = []
818
+ sourcesState->Array.forEach(
819
+ sourceState => {
820
+ if (
821
+ !sourceState.disabled &&
822
+ !(mainSources->Array.includes(sourceState)) &&
823
+ getSourceRole(
824
+ ~sourceFor=sourceState.source.sourceFor,
825
+ ~isRealtime,
826
+ ~hasRealtime=sourceManager.hasRealtime,
827
+ )->Option.isSome
828
+ ) {
829
+ fallbackSources->Array.push(sourceState)
830
+ }
831
+ },
832
+ )
833
+
834
+ switch fallbackSources {
835
+ | [] =>
836
+ logger->Logging.childWarn(
837
+ `No new blocks detected within ${(stallTimeout / 1000)
838
+ ->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`,
839
+ )
840
+ | _ =>
841
+ logger->Logging.childWarn(
842
+ `No new blocks detected within ${(stallTimeout / 1000)
843
+ ->Int.toString}s. Continuing polling with secondary RPC sources from the configuration.`,
844
+ )
845
+ }
846
+
847
+ // Recruited to poll, not to stream: see `watch`.
848
+ fallbackSources->Array.forEach(
849
+ sourceState => sourceState->watch(~withStream=false),
850
+ )
851
+
852
+ // A fallback recruited here can still be holding a stream from an
853
+ // earlier wait that made it a primary, and a live stream is not
854
+ // polled behind. It has to inherit the verdict the primaries
855
+ // already earned — this wait has heard nothing for a whole window
856
+ // — or it would sit silent behind that stream until the next
857
+ // spread poke. Distrusting a waiter that already does so is a
858
+ // no-op, so this reaches the new ones only.
859
+ if !settled.contents {
860
+ watched->Array.forEach(subscription => subscription.distrustStream())
861
+ }
862
+ }
863
+ }, stallTimeout))
874
864
 
875
- newBlockHeight
865
+ armPokeTimeout()
866
+ armStallTimeout()
867
+ }
868
+ })
876
869
  }
877
870
 
871
+
878
872
  let executeQuery = async (
879
873
  sourceManager: t,
880
874
  ~query: FetchState.query,
@@ -946,11 +940,10 @@ let executeQuery = async (
946
940
  sourceState.lastFailedAt = None
947
941
 
948
942
  // The response carries a fresh height for exactly this source, so during a
949
- // long backfill (when the wait loop doesn't run) it keeps the per-source
950
- // envio_source_known_height current.
951
- if response.knownHeight > sourceState.knownHeight {
952
- sourceState.knownHeight = response.knownHeight
953
- }
943
+ // long backfill (when nothing is waiting on the feed) it keeps the
944
+ // per-source envio_source_known_height current — and if a wait is in
945
+ // flight, a height learned this way can settle it.
946
+ sourceState.feed->HeightFeed.recordHeight(response.knownHeight)
954
947
  responseRef := Some(response)
955
948
  } catch {
956
949
  | Source.RateLimited({resetMs, requestStats}) =>