envio 3.2.1 → 3.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/package.json +6 -7
  2. package/src/Batch.res +12 -15
  3. package/src/Batch.res.mjs +4 -5
  4. package/src/BatchProcessing.res +199 -0
  5. package/src/BatchProcessing.res.mjs +125 -0
  6. package/src/ChainFetching.res +373 -0
  7. package/src/ChainFetching.res.mjs +206 -0
  8. package/src/ChainMetadata.res +27 -0
  9. package/src/ChainMetadata.res.mjs +27 -0
  10. package/src/ChainState.res +643 -0
  11. package/src/ChainState.res.mjs +476 -0
  12. package/src/ChainState.resi +87 -0
  13. package/src/Config.res +13 -4
  14. package/src/Config.res.mjs +8 -3
  15. package/src/ContractRegisterContext.res +106 -0
  16. package/src/ContractRegisterContext.res.mjs +76 -0
  17. package/src/Core.res +3 -0
  18. package/src/CrossChainState.res +294 -0
  19. package/src/CrossChainState.res.mjs +221 -0
  20. package/src/CrossChainState.resi +39 -0
  21. package/src/Ecosystem.res +58 -0
  22. package/src/Ecosystem.res.mjs +43 -1
  23. package/src/Env.res +0 -2
  24. package/src/Env.res.mjs +0 -3
  25. package/src/EventConfigBuilder.res +2 -2
  26. package/src/EventProcessing.res +60 -46
  27. package/src/EventProcessing.res.mjs +33 -32
  28. package/src/EventUtils.res +0 -4
  29. package/src/EventUtils.res.mjs +0 -4
  30. package/src/ExitOnCaughtUp.res +10 -0
  31. package/src/ExitOnCaughtUp.res.mjs +22 -0
  32. package/src/FetchState.res +205 -73
  33. package/src/FetchState.res.mjs +242 -103
  34. package/src/InMemoryStore.res +36 -451
  35. package/src/InMemoryStore.res.mjs +20 -369
  36. package/src/IndexerLoop.res +43 -0
  37. package/src/IndexerLoop.res.mjs +46 -0
  38. package/src/IndexerState.res +536 -0
  39. package/src/IndexerState.res.mjs +543 -0
  40. package/src/IndexerState.resi +128 -0
  41. package/src/Internal.res +29 -8
  42. package/src/LoadLayer.res +20 -18
  43. package/src/LoadLayer.res.mjs +14 -12
  44. package/src/LoadLayer.resi +6 -3
  45. package/src/Logging.res +11 -44
  46. package/src/Logging.res.mjs +10 -42
  47. package/src/Main.res +54 -79
  48. package/src/Main.res.mjs +44 -56
  49. package/src/PgStorage.res +8 -71
  50. package/src/PgStorage.res.mjs +3 -47
  51. package/src/Prometheus.res +1 -13
  52. package/src/Prometheus.res.mjs +84 -96
  53. package/src/PruneStaleHistory.res +45 -0
  54. package/src/PruneStaleHistory.res.mjs +46 -0
  55. package/src/RawEvent.res +73 -0
  56. package/src/RawEvent.res.mjs +51 -0
  57. package/src/Rollback.res +204 -0
  58. package/src/Rollback.res.mjs +118 -0
  59. package/src/SimulateItems.res +12 -10
  60. package/src/SimulateItems.res.mjs +1 -1
  61. package/src/UserContext.res +26 -114
  62. package/src/UserContext.res.mjs +15 -78
  63. package/src/Writing.res +242 -0
  64. package/src/Writing.res.mjs +215 -0
  65. package/src/db/InternalTable.res +14 -27
  66. package/src/sources/Evm.res +40 -1
  67. package/src/sources/Evm.res.mjs +94 -84
  68. package/src/sources/Fuel.res +40 -1
  69. package/src/sources/Fuel.res.mjs +36 -26
  70. package/src/sources/HyperFuel.res +41 -120
  71. package/src/sources/HyperFuel.res.mjs +42 -80
  72. package/src/sources/HyperFuel.resi +1 -24
  73. package/src/sources/HyperFuelClient.res +16 -297
  74. package/src/sources/HyperFuelClient.res.mjs +5 -4
  75. package/src/sources/HyperFuelSource.res +33 -8
  76. package/src/sources/HyperFuelSource.res.mjs +56 -10
  77. package/src/sources/HyperSyncSource.res +5 -3
  78. package/src/sources/HyperSyncSource.res.mjs +1 -1
  79. package/src/sources/RpcSource.res +2 -2
  80. package/src/sources/RpcSource.res.mjs +1 -1
  81. package/src/sources/SourceManager.res +14 -23
  82. package/src/sources/SourceManager.res.mjs +27 -29
  83. package/src/sources/SourceManager.resi +4 -2
  84. package/src/sources/Svm.res +18 -1
  85. package/src/sources/Svm.res.mjs +30 -22
  86. package/src/sources/SvmHyperSyncSource.res +1 -1
  87. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
  88. package/src/tui/Tui.res +38 -36
  89. package/src/tui/Tui.res.mjs +51 -51
  90. package/src/ChainFetcher.res +0 -519
  91. package/src/ChainFetcher.res.mjs +0 -314
  92. package/src/ChainManager.res +0 -358
  93. package/src/ChainManager.res.mjs +0 -291
  94. package/src/Ctx.res +0 -6
  95. package/src/Ctx.res.mjs +0 -2
  96. package/src/GlobalState.res +0 -1056
  97. package/src/GlobalState.res.mjs +0 -1086
  98. package/src/GlobalStateManager.res +0 -57
  99. package/src/GlobalStateManager.res.mjs +0 -68
  100. package/src/GlobalStateManager.resi +0 -7
  101. package/src/Ports.res +0 -5
  102. package/src/Ports.res.mjs +0 -9
  103. package/src/adapters/MarkBatchProcessedAdapter.res +0 -5
  104. package/src/adapters/MarkBatchProcessedAdapter.res.mjs +0 -14
  105. package/src/sources/EnvioApiClient.res +0 -15
  106. package/src/sources/EnvioApiClient.res.mjs +0 -24
@@ -0,0 +1,106 @@
1
+ // The contractRegister handler context: context.chain.ContractName.add(address).
2
+ // Independent of the in-memory store, so it stays off IndexerState and the
3
+ // fetch-time contract registration in ChainState doesn't pull the state in.
4
+
5
+ type contractRegisterParams = {
6
+ item: Internal.item,
7
+ onRegister: (~item: Internal.item, ~contractAddress: Address.t, ~contractName: string) => unit,
8
+ config: Config.t,
9
+ mutable isResolved: bool,
10
+ }
11
+
12
+ // Helper to create a validated add function for contract registration.
13
+ // The isResolved check has to live inside the returned closure (not just in the
14
+ // outer proxy trap) because users can capture `const add = context.chain.X.add`
15
+ // before awaiting — a later call would otherwise bypass the resolved guard.
16
+ let makeAddFunction = (~params: contractRegisterParams, ~contractName: string): (
17
+ Address.t => unit
18
+ ) => {
19
+ (contractAddress: Address.t) => {
20
+ if params.isResolved {
21
+ Utils.Error.make(`Impossible to access context.chain after the contract register is resolved. Make sure you didn't miss an await in the handler.`)->ErrorHandling.mkLogAndRaise(
22
+ ~logger=Ecosystem.getItemLogger(params.item, ~ecosystem=params.config.ecosystem),
23
+ )
24
+ }
25
+ let validatedAddress = if params.config.ecosystem.name === Evm {
26
+ // The value is passed from the user-land,
27
+ // so we need to validate and checksum/lowercase the address.
28
+ if params.config.lowercaseAddresses {
29
+ contractAddress->Address.Evm.fromAddressLowercaseOrThrow
30
+ } else {
31
+ contractAddress->Address.Evm.fromAddressOrThrow
32
+ }
33
+ } else {
34
+ // TODO: Ideally we should do the same for other ecosystems
35
+ contractAddress
36
+ }
37
+
38
+ params.onRegister(~item=params.item, ~contractAddress=validatedAddress, ~contractName)
39
+ }
40
+ }
41
+
42
+ // Chain proxy for contractRegister context: context.chain.ContractName.add(address)
43
+ let contractRegisterChainTraps: Utils.Proxy.traps<contractRegisterParams> = {
44
+ get: (~target as params, ~prop: unknown) => {
45
+ let prop = prop->(Utils.magic: unknown => string)
46
+ switch prop {
47
+ | "id" =>
48
+ let eventItem = params.item->Internal.castUnsafeEventItem
49
+ eventItem.chain->ChainMap.Chain.toChainId->(Utils.magic: int => unknown)
50
+ | _ =>
51
+ // Look up the contract name directly in config contracts across all chains.
52
+ let contractName = prop
53
+ let isValidContract =
54
+ params.config.chainMap
55
+ ->ChainMap.values
56
+ ->Array.some(chain => chain.contracts->Array.some(c => c.name === contractName))
57
+ if isValidContract {
58
+ let addFn = makeAddFunction(~params, ~contractName)
59
+ {"add": addFn}->(Utils.magic: {"add": Address.t => unit} => unknown)
60
+ } else {
61
+ JsError.throwWithMessage(
62
+ `Invalid contract name '${prop}' on context.chain. ${EntityFilter.codegenHelpMessage}`,
63
+ )
64
+ }
65
+ }
66
+ },
67
+ }
68
+
69
+ let contractRegisterTraps: Utils.Proxy.traps<contractRegisterParams> = {
70
+ get: (~target as params, ~prop: unknown) => {
71
+ let prop = prop->(Utils.magic: unknown => string)
72
+ if params.isResolved {
73
+ Utils.Error.make(
74
+ `Impossible to access context.${prop} after the contract register is resolved. Make sure you didn't miss an await in the handler.`,
75
+ )->ErrorHandling.mkLogAndRaise(
76
+ ~logger=Ecosystem.getItemLogger(params.item, ~ecosystem=params.config.ecosystem),
77
+ )
78
+ }
79
+ switch prop {
80
+ | "log" =>
81
+ Ecosystem.getItemUserLogger(params.item, ~ecosystem=params.config.ecosystem)->(
82
+ Utils.magic: Envio.logger => unknown
83
+ )
84
+
85
+ | "chain" =>
86
+ params
87
+ ->Utils.Proxy.make(contractRegisterChainTraps)
88
+ ->(Utils.magic: contractRegisterParams => unknown)
89
+ | _ =>
90
+ JsError.throwWithMessage(
91
+ `Invalid context access by '${prop}' property. Use context.chain.ContractName.add(address) to register contracts. ${EntityFilter.codegenHelpMessage}`,
92
+ )
93
+ }
94
+ },
95
+ }
96
+
97
+ let getContractRegisterContext = (params: contractRegisterParams) => {
98
+ params
99
+ ->Utils.Proxy.make(contractRegisterTraps)
100
+ ->(Utils.magic: contractRegisterParams => Internal.contractRegisterContext)
101
+ }
102
+
103
+ let getContractRegisterArgs = (params: contractRegisterParams): Internal.contractRegisterArgs => {
104
+ event: params.item->Ecosystem.getItemEvent(~ecosystem=params.config.ecosystem),
105
+ context: getContractRegisterContext(params),
106
+ }
@@ -0,0 +1,76 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Address from "./Address.res.mjs";
4
+ import * as ChainMap from "./ChainMap.res.mjs";
5
+ import * as Ecosystem from "./Ecosystem.res.mjs";
6
+ import * as EntityFilter from "./db/EntityFilter.res.mjs";
7
+ import * as ErrorHandling from "./ErrorHandling.res.mjs";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
+
10
+ function makeAddFunction(params, contractName) {
11
+ return contractAddress => {
12
+ if (params.isResolved) {
13
+ ErrorHandling.mkLogAndRaise(Ecosystem.getItemLogger(params.item, params.config.ecosystem), undefined, new Error(`Impossible to access context.chain after the contract register is resolved. Make sure you didn't miss an await in the handler.`));
14
+ }
15
+ let validatedAddress = params.config.ecosystem.name === "evm" ? (
16
+ params.config.lowercaseAddresses ? Address.Evm.fromAddressLowercaseOrThrow(contractAddress) : Address.Evm.fromAddressOrThrow(contractAddress)
17
+ ) : contractAddress;
18
+ params.onRegister(params.item, validatedAddress, contractName);
19
+ };
20
+ }
21
+
22
+ let contractRegisterChainTraps_get = (params, prop) => {
23
+ if (prop === "id") {
24
+ return params.item.chain;
25
+ }
26
+ let isValidContract = ChainMap.values(params.config.chainMap).some(chain => chain.contracts.some(c => c.name === prop));
27
+ if (isValidContract) {
28
+ return {
29
+ add: makeAddFunction(params, prop)
30
+ };
31
+ } else {
32
+ return Stdlib_JsError.throwWithMessage(`Invalid contract name '` + prop + `' on context.chain. ` + EntityFilter.codegenHelpMessage);
33
+ }
34
+ };
35
+
36
+ let contractRegisterChainTraps = {
37
+ get: contractRegisterChainTraps_get
38
+ };
39
+
40
+ let contractRegisterTraps_get = (params, prop) => {
41
+ if (params.isResolved) {
42
+ ErrorHandling.mkLogAndRaise(Ecosystem.getItemLogger(params.item, params.config.ecosystem), undefined, new Error(`Impossible to access context.` + prop + ` after the contract register is resolved. Make sure you didn't miss an await in the handler.`));
43
+ }
44
+ switch (prop) {
45
+ case "chain" :
46
+ return new Proxy(params, contractRegisterChainTraps);
47
+ case "log" :
48
+ return Ecosystem.getItemUserLogger(params.item, params.config.ecosystem);
49
+ default:
50
+ return Stdlib_JsError.throwWithMessage(`Invalid context access by '` + prop + `' property. Use context.chain.ContractName.add(address) to register contracts. ` + EntityFilter.codegenHelpMessage);
51
+ }
52
+ };
53
+
54
+ let contractRegisterTraps = {
55
+ get: contractRegisterTraps_get
56
+ };
57
+
58
+ function getContractRegisterContext(params) {
59
+ return new Proxy(params, contractRegisterTraps);
60
+ }
61
+
62
+ function getContractRegisterArgs(params) {
63
+ return {
64
+ event: Ecosystem.getItemEvent(params.item, params.config.ecosystem),
65
+ context: new Proxy(params, contractRegisterTraps)
66
+ };
67
+ }
68
+
69
+ export {
70
+ makeAddFunction,
71
+ contractRegisterChainTraps,
72
+ contractRegisterTraps,
73
+ getContractRegisterContext,
74
+ getContractRegisterArgs,
75
+ }
76
+ /* Address Not a pure module */
package/src/Core.res CHANGED
@@ -8,6 +8,7 @@ type evmHypersyncClientCtor
8
8
  type evmRpcClientCtor
9
9
  type evmDecoderCtor
10
10
  type svmHypersyncClientCtor
11
+ type hyperfuelClientCtor
11
12
 
12
13
  type addon = {
13
14
  getConfigJson: (~configPath: Null.t<string>, ~directory: Null.t<string>) => string,
@@ -20,6 +21,8 @@ type addon = {
20
21
  evmDecoder: evmDecoderCtor,
21
22
  @as("SvmHypersyncClient")
22
23
  svmHypersyncClient: svmHypersyncClientCtor,
24
+ @as("HyperfuelClient")
25
+ hyperfuelClient: hyperfuelClientCtor,
23
26
  }
24
27
 
25
28
  @module("node:module") external createRequire: string => {..} = "createRequire"
@@ -0,0 +1,294 @@
1
+ // Whole-indexer view over every chain's ChainState: the chain collection plus
2
+ // the run-wide flags derived from it. Owns the cross-chain reads and the
3
+ // batch/threshold transitions that touch every chain. `t` is mutated in place
4
+ // through the transitions below; the type is opaque in the interface.
5
+
6
+ type t = {
7
+ chainStates: dict<ChainState.t>,
8
+ // Chain ids in a stable order, so the cross-chain loops iterate the chains
9
+ // without allocating a values array on every tick.
10
+ chainIds: array<int>,
11
+ // True once every chain has caught up to head/endBlock. Monotonic during a run.
12
+ mutable isRealtime: bool,
13
+ mutable isInReorgThreshold: bool,
14
+ // Indexer-wide fetch buffer pool (item count), shared across all chains.
15
+ targetBufferSize: int,
16
+ }
17
+
18
+ // The whole-indexer fetch buffer pool, independent of chain count.
19
+ let calculateTargetBufferSize = () =>
20
+ switch Env.targetBufferSize {
21
+ | Some(size) => size
22
+ | None => 100_000
23
+ }
24
+
25
+ let make = (
26
+ ~chainStates,
27
+ ~isInReorgThreshold,
28
+ ~isRealtime,
29
+ ~targetBufferSize=calculateTargetBufferSize(),
30
+ ): t => {
31
+ let crossChainState = {
32
+ chainStates,
33
+ chainIds: chainStates->Dict.valuesToArray->Array.map(cs => (cs->ChainState.chainConfig).id),
34
+ isRealtime,
35
+ isInReorgThreshold,
36
+ targetBufferSize,
37
+ }
38
+ Prometheus.IndexingTargetBufferSize.set(~targetBufferSize)
39
+ crossChainState
40
+ }
41
+
42
+ // Resolve a chain's state by id. The id always comes from `chainIds`, which is
43
+ // derived from `chainStates`, so the entry is guaranteed present.
44
+ let getChainState = (crossChainState: t, chainId) =>
45
+ crossChainState.chainStates->Utils.Dict.dangerouslyGetByIntNonOption(chainId)->Option.getUnsafe
46
+
47
+ // --- Accessors. ---
48
+
49
+ let chainStates = (crossChainState: t) => crossChainState.chainStates
50
+ let isRealtime = (crossChainState: t) => crossChainState.isRealtime
51
+ let isInReorgThreshold = (crossChainState: t) => crossChainState.isInReorgThreshold
52
+
53
+ // Ready-to-process items across every chain — the live draw against
54
+ // targetBufferSize, which is a budget of processable events (items stuck behind
55
+ // a gap don't count toward the goal of keeping ~targetBufferSize ready).
56
+ let totalReadyCount = (crossChainState: t) => {
57
+ let total = ref(0)
58
+ for i in 0 to crossChainState.chainIds->Array.length - 1 {
59
+ let cs = crossChainState->getChainState(crossChainState.chainIds->Array.getUnsafe(i))
60
+ total := total.contents + cs->ChainState.fetchState->FetchState.bufferReadyCount
61
+ }
62
+ total.contents
63
+ }
64
+
65
+ // --- Derived (pure). ---
66
+
67
+ let nextItemIsNone = (crossChainState: t): bool =>
68
+ !Batch.hasReadyItem(
69
+ crossChainState.chainStates->Dict.valuesToArray->Array.map(ChainState.fetchState),
70
+ )
71
+
72
+ let getSafeCheckpointId = (crossChainState: t) => {
73
+ let result: ref<option<bigint>> = ref(None)
74
+
75
+ for i in 0 to crossChainState.chainIds->Array.length - 1 {
76
+ let cs = crossChainState->getChainState(crossChainState.chainIds->Array.getUnsafe(i))
77
+ switch cs->ChainState.safeCheckpointTracking {
78
+ | None => () // Skip chains with maxReorgDepth = 0
79
+ | Some(safeCheckpointTracking) =>
80
+ let safeCheckpointId =
81
+ safeCheckpointTracking->SafeCheckpointTracking.getSafeCheckpointId(
82
+ ~sourceBlockNumber=(cs->ChainState.fetchState).knownHeight,
83
+ )
84
+ switch result.contents {
85
+ | None => result := Some(safeCheckpointId)
86
+ | Some(current) if safeCheckpointId < current => result := Some(safeCheckpointId)
87
+ | _ => ()
88
+ }
89
+ }
90
+ }
91
+
92
+ switch result.contents {
93
+ | Some(id) if id > 0n => Some(id)
94
+ | _ => None // No safe checkpoint found
95
+ }
96
+ }
97
+
98
+ // --- Cross-chain transitions. ---
99
+
100
+ let createBatch = (
101
+ crossChainState: t,
102
+ ~processedCheckpointId,
103
+ ~batchSizeTarget: int,
104
+ ~isRollback: bool,
105
+ ): Batch.t => {
106
+ Batch.make(
107
+ ~isInReorgThreshold=crossChainState.isInReorgThreshold,
108
+ ~checkpointIdBeforeBatch=processedCheckpointId->BigInt.add(
109
+ // Since for rollback we have a diff checkpoint id.
110
+ // This is needed to currectly overwrite old state
111
+ // in an append-only ClickHouse insert.
112
+ isRollback ? 1n : 0n,
113
+ ),
114
+ ~chainsBeforeBatch=crossChainState.chainStates->Utils.Dict.mapValues((
115
+ cs
116
+ ): Batch.chainBeforeBatch => {
117
+ fetchState: cs->ChainState.fetchState,
118
+ progressBlockNumber: cs->ChainState.committedProgressBlockNumber,
119
+ totalEventsProcessed: cs->ChainState.numEventsProcessed,
120
+ sourceBlockNumber: (cs->ChainState.fetchState).knownHeight,
121
+ reorgDetection: cs->ChainState.reorgDetection,
122
+ chainConfig: cs->ChainState.chainConfig,
123
+ }),
124
+ ~batchSizeTarget,
125
+ )
126
+ }
127
+
128
+ // Enter the reorg threshold: shrink each chain's buffer by its configured
129
+ // blockLag and flip the flag.
130
+ let enterReorgThreshold = (crossChainState: t) => {
131
+ Logging.info("Reorg threshold reached")
132
+ Prometheus.ReorgThreshold.set(~isInReorgThreshold=true)
133
+
134
+ for i in 0 to crossChainState.chainIds->Array.length - 1 {
135
+ crossChainState
136
+ ->getChainState(crossChainState.chainIds->Array.getUnsafe(i))
137
+ ->ChainState.enterReorgThreshold
138
+ }
139
+
140
+ crossChainState.isInReorgThreshold = true
141
+ }
142
+
143
+ // Commit each progressed chain's batch progress, then decide readiness for the
144
+ // whole indexer. A chain is marked caught up only once EVERY chain is caught up
145
+ // (reached endblock or fetched/processed to head) with no processable events
146
+ // left — so no chain flips to ready while another is still backfilling.
147
+ let applyBatchProgress = (crossChainState: t, ~batch: Batch.t) => {
148
+ let chainIds = crossChainState.chainIds
149
+
150
+ let everyChainCaughtUp = ref(true)
151
+ for i in 0 to chainIds->Array.length - 1 {
152
+ let cs = crossChainState->getChainState(chainIds->Array.getUnsafe(i))
153
+ cs->ChainState.applyBatchProgress(~batch)
154
+ if !(cs->ChainState.hasProcessedToEndblock || cs->ChainState.isProgressAtHead) {
155
+ everyChainCaughtUp := false
156
+ }
157
+ }
158
+
159
+ let indexerCaughtUp = crossChainState->nextItemIsNone && everyChainCaughtUp.contents
160
+
161
+ let allChainsReady = ref(true)
162
+ for i in 0 to chainIds->Array.length - 1 {
163
+ let cs = crossChainState->getChainState(chainIds->Array.getUnsafe(i))
164
+ if indexerCaughtUp {
165
+ cs->ChainState.markReady
166
+ }
167
+ if !(cs->ChainState.isReady) {
168
+ allChainsReady := false
169
+ }
170
+ }
171
+
172
+ if allChainsReady.contents {
173
+ Prometheus.ProgressReady.setAllReady()
174
+ }
175
+
176
+ crossChainState.isRealtime = crossChainState.isRealtime || allChainsReady.contents
177
+ }
178
+
179
+ // --- Fetch control. ---
180
+
181
+ // Chains ordered furthest-behind first, so the shared buffer pool goes to the
182
+ // chains with the most backfill work before the rest.
183
+ let priorityOrder = (crossChainState: t) =>
184
+ crossChainState.chainStates
185
+ ->Dict.valuesToArray
186
+ ->Array.toSorted((a, b) =>
187
+ Float.compare(
188
+ a->ChainState.fetchState->FetchState.getProgressPercentage,
189
+ b->ChainState.fetchState->FetchState.getProgressPercentage,
190
+ )
191
+ )
192
+
193
+ // In-flight estimated items across every chain — the live draw against
194
+ // targetBufferSize alongside totalReadyCount, so the pool isn't re-dispatched
195
+ // while queries are still being fetched.
196
+ let totalReservedSize = (crossChainState: t) => {
197
+ let total = ref(0.)
198
+ for i in 0 to crossChainState.chainIds->Array.length - 1 {
199
+ let cs = crossChainState->getChainState(crossChainState.chainIds->Array.getUnsafe(i))
200
+ total := total.contents +. cs->ChainState.pendingBudget
201
+ }
202
+ total.contents
203
+ }
204
+
205
+ // Furthest-behind first: order candidate queries by the chain progress % at
206
+ // their fromBlock, so the most behind ranges across all chains are admitted
207
+ // before the rest.
208
+ let compareByProgress = (a: FetchState.query, b: FetchState.query) =>
209
+ Float.compare(a.progress, b.progress)
210
+
211
+ // Dispatch a fetch tick across the whole indexer from one shared pool of
212
+ // ~targetBufferSize ready events. Every chain proposes its candidate queries
213
+ // (each carrying an estimated response size) against the full free budget; the
214
+ // candidates are then pooled, ordered by chain progress (furthest-behind first),
215
+ // and admitted until the budget is consumed. So the budget is split per query
216
+ // across chains rather than per chain — a chain that can only use a little
217
+ // leaves the rest for the others automatically.
218
+ let checkAndFetch = async (
219
+ crossChainState: t,
220
+ ~dispatchChain: (~chain: ChainMap.Chain.t, ~action: FetchState.nextQuery) => promise<unit>,
221
+ ) => {
222
+ let remaining = Pervasives.max(
223
+ 0,
224
+ crossChainState.targetBufferSize -
225
+ crossChainState->totalReadyCount -
226
+ crossChainState->totalReservedSize->Float.toInt,
227
+ )
228
+
229
+ let chainIds = crossChainState.chainIds
230
+ let actionByChain = Dict.make()
231
+ // Candidate queries from every chain. Each query carries its chain id and the
232
+ // chain progress % at its fromBlock (the admission sort key), set here so the
233
+ // pool can be ordered without a side tuple per query.
234
+ let candidates = []
235
+ for i in 0 to chainIds->Array.length - 1 {
236
+ let chainId = chainIds->Array.getUnsafe(i)
237
+ let cs = crossChainState->getChainState(chainId)
238
+ let fetchState = cs->ChainState.fetchState
239
+ switch fetchState->FetchState.getNextQuery(
240
+ ~budget=remaining,
241
+ ~chainPendingBudget=cs->ChainState.pendingBudget,
242
+ ) {
243
+ | (WaitingForNewBlock | NothingToQuery) as action =>
244
+ actionByChain->Utils.Dict.setByInt(chainId, action)
245
+ | Ready(queries) =>
246
+ // Default to NothingToQuery; replaced below if any candidate is admitted.
247
+ actionByChain->Utils.Dict.setByInt(chainId, FetchState.NothingToQuery)
248
+ queries->Array.forEach(query => {
249
+ query.chainId = chainId
250
+ query.progress =
251
+ fetchState->FetchState.getProgressPercentageAt(~blockNumber=query.fromBlock)
252
+ candidates->Array.push(query)
253
+ })
254
+ }
255
+ }
256
+
257
+ candidates->Array.sort(compareByProgress)
258
+
259
+ // Admit furthest-behind first until the budget runs out. The condition is
260
+ // checked before each query, so as long as there's any budget left we admit
261
+ // the next one even when its estimate alone exceeds the remainder — otherwise a
262
+ // chain whose only query is bigger than the budget would never make progress.
263
+ let admittedByChain = Dict.make()
264
+ let running = ref(0.)
265
+ let remainingF = remaining->Int.toFloat
266
+ let idx = ref(0)
267
+ while running.contents < remainingF && idx.contents < candidates->Array.length {
268
+ let query = candidates->Array.getUnsafe(idx.contents)
269
+ admittedByChain->Utils.Dict.push(query.chainId->Int.toString, query)
270
+ running := running.contents +. query.estResponseSize
271
+ idx := idx.contents + 1
272
+ }
273
+ admittedByChain->Dict.forEachWithKey((queries, chainId) => {
274
+ actionByChain->Dict.set(chainId, FetchState.Ready(queries))
275
+ // Mark the admitted queries in flight and reserve their size against the
276
+ // shared budget; released as each response lands in handleQueryResult.
277
+ crossChainState
278
+ ->getChainState(chainId->Int.fromString->Option.getUnsafe)
279
+ ->ChainState.startFetchingQueries(~queries)
280
+ })
281
+
282
+ let promises = []
283
+ for i in 0 to chainIds->Array.length - 1 {
284
+ let chainId = chainIds->Array.getUnsafe(i)
285
+ switch actionByChain->Utils.Dict.dangerouslyGetByIntNonOption(chainId) {
286
+ | Some(NothingToQuery)
287
+ | None => ()
288
+ | Some(action) =>
289
+ let chain = ChainMap.Chain.makeUnsafe(~chainId)
290
+ promises->Array.push(dispatchChain(~chain, ~action))
291
+ }
292
+ }
293
+ let _ = await promises->Promise.all
294
+ }