envio 3.3.0-alpha.0 → 3.3.0-alpha.10

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 (140) hide show
  1. package/evm.schema.json +10 -0
  2. package/index.d.ts +45 -25
  3. package/package.json +7 -7
  4. package/src/Address.res +5 -2
  5. package/src/Address.res.mjs +3 -1
  6. package/src/Api.res +1 -1
  7. package/src/Api.res.mjs +1 -1
  8. package/src/Batch.res +0 -6
  9. package/src/Batch.res.mjs +1 -12
  10. package/src/BatchProcessing.res +7 -12
  11. package/src/BatchProcessing.res.mjs +8 -7
  12. package/src/Bin.res +3 -0
  13. package/src/Bin.res.mjs +4 -0
  14. package/src/ChainFetching.res +48 -16
  15. package/src/ChainFetching.res.mjs +36 -17
  16. package/src/ChainMetadata.res +1 -11
  17. package/src/ChainMetadata.res.mjs +1 -10
  18. package/src/ChainState.res +628 -149
  19. package/src/ChainState.res.mjs +440 -102
  20. package/src/ChainState.resi +74 -4
  21. package/src/Config.res +61 -33
  22. package/src/Config.res.mjs +50 -10
  23. package/src/ContractRegisterContext.res +2 -12
  24. package/src/ContractRegisterContext.res.mjs +3 -5
  25. package/src/Core.res +14 -3
  26. package/src/CrossChainState.res +142 -93
  27. package/src/CrossChainState.res.mjs +88 -60
  28. package/src/CrossChainState.resi +2 -10
  29. package/src/Ecosystem.res +5 -6
  30. package/src/Ecosystem.res.mjs +3 -3
  31. package/src/Env.res +0 -7
  32. package/src/Env.res.mjs +0 -6
  33. package/src/Envio.res +17 -11
  34. package/src/EnvioGlobal.res +53 -0
  35. package/src/EnvioGlobal.res.mjs +31 -0
  36. package/src/EventConfigBuilder.res +177 -88
  37. package/src/EventConfigBuilder.res.mjs +99 -42
  38. package/src/EventProcessing.res +74 -28
  39. package/src/EventProcessing.res.mjs +55 -29
  40. package/src/ExitOnCaughtUp.res +10 -2
  41. package/src/ExitOnCaughtUp.res.mjs +10 -1
  42. package/src/FetchState.res +558 -300
  43. package/src/FetchState.res.mjs +437 -423
  44. package/src/HandlerLoader.res +8 -104
  45. package/src/HandlerLoader.res.mjs +2 -81
  46. package/src/HandlerRegister.res +518 -144
  47. package/src/HandlerRegister.res.mjs +285 -133
  48. package/src/HandlerRegister.resi +24 -8
  49. package/src/IndexerLoop.res +2 -3
  50. package/src/IndexerLoop.res.mjs +1 -1
  51. package/src/IndexerState.res +10 -11
  52. package/src/IndexerState.res.mjs +14 -11
  53. package/src/IndexerState.resi +2 -5
  54. package/src/IndexingAddresses.res +108 -0
  55. package/src/IndexingAddresses.res.mjs +101 -0
  56. package/src/IndexingAddresses.resi +34 -0
  57. package/src/Internal.res +155 -46
  58. package/src/Internal.res.mjs +30 -2
  59. package/src/LoadLayer.res +5 -5
  60. package/src/LoadLayer.res.mjs +6 -6
  61. package/src/LogSelection.res +102 -165
  62. package/src/LogSelection.res.mjs +101 -116
  63. package/src/Main.res +71 -236
  64. package/src/Main.res.mjs +60 -145
  65. package/src/Metrics.res +74 -0
  66. package/src/Metrics.res.mjs +75 -0
  67. package/src/PgStorage.res +4 -4
  68. package/src/PgStorage.res.mjs +5 -5
  69. package/src/Prometheus.res +10 -74
  70. package/src/Prometheus.res.mjs +124 -186
  71. package/src/PruneStaleHistory.res +2 -2
  72. package/src/PruneStaleHistory.res.mjs +3 -3
  73. package/src/RawEvent.res +7 -2
  74. package/src/RawEvent.res.mjs +4 -4
  75. package/src/Rollback.res +3 -3
  76. package/src/Rollback.res.mjs +4 -4
  77. package/src/RollbackCommit.res +4 -1
  78. package/src/RollbackCommit.res.mjs +3 -2
  79. package/src/SimulateDeadInputTracker.res +85 -0
  80. package/src/SimulateDeadInputTracker.res.mjs +73 -0
  81. package/src/SimulateDeadInputTracker.resi +12 -0
  82. package/src/SimulateItems.res +91 -23
  83. package/src/SimulateItems.res.mjs +55 -32
  84. package/src/TestIndexer.res +54 -40
  85. package/src/TestIndexer.res.mjs +37 -30
  86. package/src/bindings/Performance.res +7 -0
  87. package/src/bindings/Performance.res.mjs +21 -0
  88. package/src/bindings/Performance.resi +7 -0
  89. package/src/bindings/Viem.res +0 -41
  90. package/src/bindings/Viem.res.mjs +1 -43
  91. package/src/sources/BlockStore.res +46 -0
  92. package/src/sources/BlockStore.res.mjs +24 -0
  93. package/src/sources/EventRouter.res +36 -33
  94. package/src/sources/EventRouter.res.mjs +13 -14
  95. package/src/sources/Evm.res +83 -72
  96. package/src/sources/Evm.res.mjs +53 -69
  97. package/src/sources/EvmChain.res +18 -20
  98. package/src/sources/EvmChain.res.mjs +15 -15
  99. package/src/sources/EvmRpcClient.res +74 -5
  100. package/src/sources/EvmRpcClient.res.mjs +13 -4
  101. package/src/sources/FieldMask.res +39 -0
  102. package/src/sources/FieldMask.res.mjs +42 -0
  103. package/src/sources/Fuel.res +9 -6
  104. package/src/sources/Fuel.res.mjs +5 -10
  105. package/src/sources/HyperFuelSource.res +46 -50
  106. package/src/sources/HyperFuelSource.res.mjs +64 -54
  107. package/src/sources/HyperSync.res +39 -7
  108. package/src/sources/HyperSync.res.mjs +57 -37
  109. package/src/sources/HyperSync.resi +12 -2
  110. package/src/sources/HyperSyncClient.res +22 -111
  111. package/src/sources/HyperSyncClient.res.mjs +2 -30
  112. package/src/sources/HyperSyncHeightStream.res +1 -8
  113. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  114. package/src/sources/HyperSyncSource.res +94 -107
  115. package/src/sources/HyperSyncSource.res.mjs +82 -86
  116. package/src/sources/Rpc.res +15 -47
  117. package/src/sources/Rpc.res.mjs +25 -56
  118. package/src/sources/RpcSource.res +320 -467
  119. package/src/sources/RpcSource.res.mjs +268 -385
  120. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  121. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  122. package/src/sources/SimulateSource.res +10 -4
  123. package/src/sources/SimulateSource.res.mjs +16 -6
  124. package/src/sources/Source.res +34 -6
  125. package/src/sources/SourceManager.res +86 -27
  126. package/src/sources/SourceManager.res.mjs +84 -47
  127. package/src/sources/SourceManager.resi +12 -3
  128. package/src/sources/Svm.res +31 -16
  129. package/src/sources/Svm.res.mjs +36 -13
  130. package/src/sources/SvmHyperSyncClient.res +5 -32
  131. package/src/sources/SvmHyperSyncSource.res +159 -134
  132. package/src/sources/SvmHyperSyncSource.res.mjs +164 -129
  133. package/src/sources/TransactionStore.res +49 -0
  134. package/src/sources/TransactionStore.res.mjs +30 -0
  135. package/src/tui/Tui.res +13 -16
  136. package/src/tui/Tui.res.mjs +12 -14
  137. package/svm.schema.json +49 -37
  138. package/src/bindings/Hrtime.res +0 -58
  139. package/src/bindings/Hrtime.res.mjs +0 -90
  140. package/src/bindings/Hrtime.resi +0 -30
@@ -5,14 +5,56 @@
5
5
  type t = {
6
6
  logger: Pino.t,
7
7
  mutable fetchState: FetchState.t,
8
+ // The chain-wide address index. Not `mutable`: the dict is mutated in place by
9
+ // register/rollback, so the reference is stable across fetchState versions.
10
+ indexingAddresses: IndexingAddresses.t,
8
11
  sourceManager: SourceManager.t,
9
12
  chainConfig: Config.chain,
10
13
  mutable isProgressAtHead: bool,
11
14
  mutable timestampCaughtUpToHeadOrEndblock: option<Date.t>,
12
15
  mutable committedProgressBlockNumber: int,
16
+ // Progress of the batch currently being processed. The buffer is consumed at
17
+ // batch creation (see advanceAfterBatch), so this runs ahead of
18
+ // committedProgressBlockNumber until the batch commits — it's the true lower
19
+ // boundary of the remaining buffer's block span.
20
+ mutable processingBlockNumber: int,
13
21
  mutable numEventsProcessed: float,
22
+ // Running sum of in-flight queries' itemsEst, kept here so the
23
+ // scheduler doesn't re-sum pending queries on every tick. Incremented when
24
+ // queries are dispatched, decremented as their responses land.
25
+ mutable pendingBudget: float,
26
+ // Chain-wide events/block, used to turn a chain's item budget into a target
27
+ // block for query sizing. Seeded from cumulative progress on construction,
28
+ // then smoothed with an EMA on every batch (see applyBatchProgress). None
29
+ // until the chain has processed at least one event.
30
+ mutable chainDensity: option<float>,
14
31
  mutable reorgDetection: ReorgDetection.t,
15
32
  mutable safeCheckpointTracking: option<SafeCheckpointTracking.t>,
33
+ // Holds this chain's transactions (kept in Rust) keyed by (blockNumber,
34
+ // transactionIndex). Fetch responses merge their page in; entries are pruned
35
+ // as the chain progresses and dropped above the target on rollback.
36
+ transactionStore: TransactionStore.t,
37
+ // Holds this chain's blocks (kept in Rust) keyed by block number. Same merge /
38
+ // prune / rollback lifecycle as the transaction store.
39
+ blockStore: BlockStore.t,
40
+ }
41
+
42
+ // Per-chain shape returned by the status API.
43
+ type chainData = {
44
+ chainId: float,
45
+ poweredByHyperSync: bool,
46
+ firstEventBlockNumber: option<int>,
47
+ latestProcessedBlock: option<int>,
48
+ timestampCaughtUpToHeadOrEndblock: option<Date.t>,
49
+ numEventsProcessed: float,
50
+ latestFetchedBlockNumber: int,
51
+ // Need this for API backwards compatibility
52
+ @as("currentBlockHeight")
53
+ knownHeight: int,
54
+ numBatchesFetched: int,
55
+ startBlock: int,
56
+ endBlock: option<int>,
57
+ numAddresses: int,
16
58
  }
17
59
 
18
60
  let configAddresses = (chainConfig: Config.chain): array<Internal.indexingAddress> => {
@@ -32,6 +74,7 @@ let configAddresses = (chainConfig: Config.chain): array<Internal.indexingAddres
32
74
  let make = (
33
75
  ~chainConfig: Config.chain,
34
76
  ~fetchState: FetchState.t,
77
+ ~indexingAddresses: IndexingAddresses.t,
35
78
  ~sourceManager: SourceManager.t,
36
79
  ~reorgDetection: ReorgDetection.t,
37
80
  ~committedProgressBlockNumber: int,
@@ -39,18 +82,27 @@ let make = (
39
82
  ~numEventsProcessed=0.,
40
83
  ~timestampCaughtUpToHeadOrEndblock=None,
41
84
  ~isProgressAtHead=false,
85
+ ~transactionStore=TransactionStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
86
+ ~chainDensity=None,
87
+ ~blockStore=BlockStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
42
88
  ~logger: Pino.t,
43
89
  ): t => {
44
90
  logger,
45
91
  fetchState,
92
+ indexingAddresses,
46
93
  sourceManager,
47
94
  chainConfig,
48
95
  isProgressAtHead,
49
96
  timestampCaughtUpToHeadOrEndblock,
50
97
  committedProgressBlockNumber,
98
+ processingBlockNumber: committedProgressBlockNumber,
51
99
  numEventsProcessed,
100
+ pendingBudget: 0.,
101
+ chainDensity,
52
102
  reorgDetection,
53
103
  safeCheckpointTracking,
104
+ transactionStore,
105
+ blockStore,
54
106
  }
55
107
 
56
108
  let makeInternal = (
@@ -61,7 +113,7 @@ let makeInternal = (
61
113
  ~firstEventBlock=None,
62
114
  ~progressBlockNumber,
63
115
  ~config: Config.t,
64
- ~registrations: HandlerRegister.registrations,
116
+ ~registrationsByChainId: HandlerRegister.registrationsByChainId,
65
117
  ~logger,
66
118
  ~timestampCaughtUpToHeadOrEndblock,
67
119
  ~numEventsProcessed,
@@ -72,129 +124,35 @@ let makeInternal = (
72
124
  ~knownHeight=0,
73
125
  ~reducedPollingInterval=?,
74
126
  ): t => {
75
- // We don't need the router itself, but only validation logic,
76
- // since now event router is created for selection of events
77
- // and validation doesn't work correctly in routers.
78
- // Ideally to split it into two different parts.
79
- let eventRouter = EventRouter.empty()
80
-
81
- // Aggregate events we want to fetch
82
- let eventConfigs: array<Internal.eventConfig> = []
83
-
84
- let notRegisteredEvents = []
127
+ // Handler binding + `where`-derived fetch state, and onBlock registrations,
128
+ // are already collected by `HandlerRegister.finishRegistration`, keyed by
129
+ // chain - this just looks up this chain's slice.
130
+ let {onEventRegistrations, onBlockRegistrations} =
131
+ registrationsByChainId
132
+ ->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
133
+ ->Option.getOr({onEventRegistrations: [], onBlockRegistrations: []})
85
134
 
86
135
  chainConfig.contracts->Array.forEach(contract => {
87
- let contractName = contract.name
88
-
89
- contract.events->Array.forEach(eventConfig => {
90
- let {isWildcard} = eventConfig
91
- let hasContractRegister = eventConfig.contractRegister->Option.isSome
92
-
93
- // Should validate the events
94
- eventRouter->EventRouter.addOrThrow(
95
- eventConfig.id,
96
- (),
97
- ~contractName,
98
- ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id),
99
- ~eventName=eventConfig.name,
100
- ~isWildcard,
101
- )
102
-
103
- // Filter out non-preRegistration events on preRegistration phase
104
- // so we don't care about it in fetch state and workers anymore
105
- let shouldBeIncluded = if config.enableRawEvents {
106
- true
107
- } else {
108
- let isRegistered = hasContractRegister || eventConfig.handler->Option.isSome
109
- if !isRegistered {
110
- notRegisteredEvents->Array.push(eventConfig)
111
- }
112
- isRegistered
113
- }
114
-
115
- // Check if event has Static([]) filters (from a dynamic where
116
- // callback returning `false` / SkipAll for this chain).
117
- // If so, skip it entirely - it should never be fetched
118
- let shouldSkip = try {
119
- let getEventFiltersOrThrow = (
120
- eventConfig->(Utils.magic: Internal.eventConfig => Internal.evmEventConfig)
121
- ).getEventFiltersOrThrow
122
-
123
- // Check for non-evm chains
124
- if (
125
- getEventFiltersOrThrow->(Utils.magic: (ChainMap.Chain.t => Internal.eventFilters) => bool)
126
- ) {
127
- switch getEventFiltersOrThrow(ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)) {
128
- | Static([]) => true
129
- | _ => false
130
- }
131
- } else {
132
- false
133
- }
134
- } catch {
135
- // Can throw when filter is invalid
136
- // Don't skip an event in this case. Let it throw in a better place - source code
137
- | _ => false
138
- }
139
-
140
- if shouldBeIncluded && !shouldSkip {
141
- eventConfigs->Array.push(eventConfig)
142
- }
143
- })
144
-
145
136
  switch contract.startBlock {
146
137
  | Some(startBlock) if startBlock < chainConfig.startBlock =>
147
138
  JsError.throwWithMessage(
148
- `The start block for contract "${contractName}" is less than the chain start block. This is not supported yet.`,
139
+ `The start block for contract "${contract.name}" is less than the chain start block. This is not supported yet.`,
149
140
  )
150
141
  | _ => ()
151
142
  }
152
143
  })
153
144
 
154
- if notRegisteredEvents->Utils.Array.notEmpty {
155
- logger->Logging.childInfo(
156
- `The event${if notRegisteredEvents->Array.length > 1 {
157
- "s"
158
- } else {
159
- ""
160
- }} ${notRegisteredEvents
161
- ->Array.map(eventConfig => `${eventConfig.contractName}.${eventConfig.name}`)
162
- ->Array.joinUnsafe(", ")} don't have an event handler and skipped for indexing.`,
163
- )
164
- }
165
-
166
- let onBlockConfigs =
167
- registrations.onBlockByChainId->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
168
- switch onBlockConfigs {
169
- | Some(onBlockConfigs) =>
170
- // TODO: Move it to the HandlerRegister module
171
- // so the error is thrown with better stack trace
172
- onBlockConfigs->Array.forEach(onBlockConfig => {
173
- if onBlockConfig.startBlock->Option.getOr(startBlock) < startBlock {
174
- JsError.throwWithMessage(
175
- `The start block for onBlock handler "${onBlockConfig.name}" is less than the chain start block (${startBlock->Int.toString}). This is not supported yet.`,
176
- )
177
- }
178
- switch endBlock {
179
- | Some(chainEndBlock) =>
180
- if onBlockConfig.endBlock->Option.getOr(chainEndBlock) > chainEndBlock {
181
- JsError.throwWithMessage(
182
- `The end block for onBlock handler "${onBlockConfig.name}" is greater than the chain end block (${chainEndBlock->Int.toString}). This is not supported yet.`,
183
- )
184
- }
185
- | None => ()
186
- }
187
- })
188
- | None => ()
189
- }
145
+ let contractConfigs = IndexingAddresses.makeContractConfigs(~onEventRegistrations)
146
+ let indexingAddressIndex = IndexingAddresses.make(~contractConfigs, ~addresses=indexingAddresses)
190
147
 
191
148
  let fetchState = FetchState.make(
192
149
  ~maxAddrInPartition=config.maxAddrInPartition,
150
+ ~contractConfigs,
193
151
  ~addresses=indexingAddresses,
194
152
  ~progressBlockNumber,
195
153
  ~startBlock,
196
154
  ~endBlock,
197
- ~eventConfigs,
155
+ ~onEventRegistrations,
198
156
  ~maxOnBlockBufferSize=2 * config.batchSize,
199
157
  ~knownHeight,
200
158
  ~chainId=chainConfig.id,
@@ -203,7 +161,7 @@ let makeInternal = (
203
161
  !config.shouldRollbackOnReorg || isInReorgThreshold ? 0 : chainConfig.maxReorgDepth,
204
162
  chainConfig.blockLag,
205
163
  ),
206
- ~onBlockConfigs?,
164
+ ~onBlockRegistrations,
207
165
  ~firstEventBlock,
208
166
  )
209
167
 
@@ -220,29 +178,23 @@ let makeInternal = (
220
178
  let lowercaseAddresses = config.lowercaseAddresses
221
179
  let sources = switch chainConfig.sourceConfig {
222
180
  | Config.EvmSourceConfig({hypersync, rpcs}) =>
223
- // Build Internal.evmContractConfig from contracts for EvmChain.makeSources
224
- let evmContracts: array<Internal.evmContractConfig> = chainConfig.contracts->Array.map((
225
- contract
226
- ): Internal.evmContractConfig => {
227
- name: contract.name,
228
- abi: contract.abi,
229
- events: contract.events->(
230
- Utils.magic: array<Internal.eventConfig> => array<Internal.evmEventConfig>
231
- ),
232
- })
233
181
  let evmRpcs: array<EvmChain.rpc> = rpcs->Array.map((rpc): EvmChain.rpc => {
234
182
  let syncConfig = rpc.syncConfig
235
183
  let ws = rpc.ws
184
+ let headers = rpc.headers
236
185
  {
237
186
  url: rpc.url,
238
187
  sourceFor: rpc.sourceFor,
239
188
  ?syncConfig,
240
189
  ?ws,
190
+ ?headers,
241
191
  }
242
192
  })
243
193
  EvmChain.makeSources(
244
194
  ~chain,
245
- ~contracts=evmContracts,
195
+ ~onEventRegistrations=onEventRegistrations->(
196
+ Utils.magic: array<Internal.onEventRegistration> => array<Internal.evmOnEventRegistration>
197
+ ),
246
198
  ~hyperSync=hypersync,
247
199
  ~rpcs=evmRpcs,
248
200
  ~lowercaseAddresses,
@@ -260,17 +212,13 @@ let makeInternal = (
260
212
  | (Some(hypersyncUrl), _) =>
261
213
  // HyperSync drives instruction sync. A configured RPC is ignored for now
262
214
  // (RPC fallback isn't wired up yet).
263
- let svmEventConfigs =
264
- chainConfig.contracts
265
- ->Array.flatMap(contract => contract.events)
266
- ->(Utils.magic: array<Internal.eventConfig> => array<Internal.svmInstructionEventConfig>)
267
215
  let apiToken = Env.envioApiToken
268
216
  [
269
217
  SvmHyperSyncSource.make({
270
218
  chain,
271
219
  endpointUrl: hypersyncUrl,
272
220
  apiToken,
273
- eventConfigs: svmEventConfigs,
221
+ onEventRegistrations,
274
222
  clientTimeoutMillis: Env.hyperSyncClientTimeoutMillis,
275
223
  }),
276
224
  ]
@@ -279,9 +227,18 @@ let makeInternal = (
279
227
  | Config.CustomSources(sources) => sources
280
228
  }
281
229
 
230
+ // Seed chain density from whatever progress this chain already has (from a
231
+ // resumed DB state, or 0 on a fresh chain) — refined per-batch afterwards.
232
+ let chainDensity = switch fetchState.firstEventBlock {
233
+ | Some(firstEventBlock) if progressBlockNumber > firstEventBlock && numEventsProcessed > 0. =>
234
+ Some(numEventsProcessed /. (progressBlockNumber - firstEventBlock)->Int.toFloat)
235
+ | _ => None
236
+ }
237
+
282
238
  make(
283
239
  ~chainConfig,
284
240
  ~fetchState,
241
+ ~indexingAddresses=indexingAddressIndex,
285
242
  ~sourceManager=SourceManager.make(~sources, ~isRealtime, ~reducedPollingInterval?),
286
243
  ~reorgDetection=ReorgDetection.make(
287
244
  ~chainReorgCheckpoints,
@@ -296,17 +253,31 @@ let makeInternal = (
296
253
  ~committedProgressBlockNumber=progressBlockNumber,
297
254
  ~timestampCaughtUpToHeadOrEndblock,
298
255
  ~numEventsProcessed,
256
+ ~transactionStore=TransactionStore.make(
257
+ ~ecosystem=config.ecosystem.name,
258
+ ~shouldChecksum=!lowercaseAddresses,
259
+ ),
260
+ ~chainDensity,
261
+ ~blockStore=BlockStore.make(
262
+ ~ecosystem=config.ecosystem.name,
263
+ ~shouldChecksum=!lowercaseAddresses,
264
+ ),
299
265
  ~logger,
300
266
  )
301
267
  }
302
268
 
303
- let makeFromConfig = (chainConfig: Config.chain, ~config, ~registrations, ~knownHeight) => {
269
+ let makeFromConfig = (
270
+ chainConfig: Config.chain,
271
+ ~config,
272
+ ~registrationsByChainId,
273
+ ~knownHeight,
274
+ ) => {
304
275
  let logger = Logging.createChild(~params={"chainId": chainConfig.id})
305
276
 
306
277
  makeInternal(
307
278
  ~chainConfig,
308
279
  ~config,
309
- ~registrations,
280
+ ~registrationsByChainId,
310
281
  ~startBlock=chainConfig.startBlock,
311
282
  ~endBlock=chainConfig.endBlock,
312
283
  ~reorgCheckpoints=[],
@@ -332,7 +303,7 @@ let makeFromDbState = (
332
303
  ~isInReorgThreshold,
333
304
  ~isRealtime,
334
305
  ~config,
335
- ~registrations,
306
+ ~registrationsByChainId,
336
307
  ~reducedPollingInterval=?,
337
308
  ) => {
338
309
  let chainId = chainConfig.id
@@ -352,7 +323,7 @@ let makeFromDbState = (
352
323
  ~startBlock=resumedChainState.startBlock,
353
324
  ~endBlock=resumedChainState.endBlock,
354
325
  ~config,
355
- ~registrations,
326
+ ~registrationsByChainId,
356
327
  ~reorgCheckpoints,
357
328
  ~maxReorgDepth=resumedChainState.maxReorgDepth,
358
329
  ~firstEventBlock=resumedChainState.firstEventBlockNumber,
@@ -372,7 +343,6 @@ let makeFromDbState = (
372
343
  // --- Read accessors. ---
373
344
 
374
345
  let logger = (cs: t) => cs.logger
375
- let fetchState = (cs: t) => cs.fetchState
376
346
  let sourceManager = (cs: t) => cs.sourceManager
377
347
  let chainConfig = (cs: t) => cs.chainConfig
378
348
  let reorgDetection = (cs: t) => cs.reorgDetection
@@ -380,8 +350,193 @@ let safeCheckpointTracking = (cs: t) => cs.safeCheckpointTracking
380
350
  let isProgressAtHead = (cs: t) => cs.isProgressAtHead
381
351
  let committedProgressBlockNumber = (cs: t) => cs.committedProgressBlockNumber
382
352
  let numEventsProcessed = (cs: t) => cs.numEventsProcessed
353
+ let pendingBudget = (cs: t) => cs.pendingBudget
383
354
  let timestampCaughtUpToHeadOrEndblock = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock
384
355
 
356
+ // Fetch-frontier reads. The FetchState is owned here; callers go through these
357
+ // rather than reaching into it.
358
+ let knownHeight = (cs: t) => cs.fetchState.knownHeight
359
+ let contractAddresses = (cs: t, ~contractName) =>
360
+ cs.indexingAddresses->IndexingAddresses.getContractAddresses(~contractName)
361
+ let bufferSize = (cs: t) => cs.fetchState->FetchState.bufferSize
362
+ let bufferReadyCount = (cs: t) => cs.fetchState->FetchState.bufferReadyCount
363
+ let getProgressPercentage = (cs: t) => cs.fetchState->FetchState.getProgressPercentage
364
+ let chainDensity = (cs: t) => cs.chainDensity
365
+ let hasReadyItem = (cs: t) =>
366
+ cs.fetchState->FetchState.isActivelyIndexing && cs.fetchState->FetchState.hasReadyItem
367
+ let isReadyToEnterReorgThreshold = (cs: t) => cs.fetchState->FetchState.isReadyToEnterReorgThreshold
368
+
369
+ // Mark queries as in flight and reserve their estimated size against the shared
370
+ // buffer budget in one step, so the counter stays in sync with the pending
371
+ // queries it tracks.
372
+ let startFetchingQueries = (cs: t, ~queries: array<FetchState.query>) => {
373
+ cs.fetchState->FetchState.startFetchingQueries(~queries)
374
+ cs.pendingBudget =
375
+ cs.pendingBudget +. queries->Array.reduce(0., (acc, query) => acc +. query.itemsEst->Int.toFloat)
376
+ }
377
+
378
+ // Drop every in-flight query and release their reservations together, keeping
379
+ // pendingBudget coupled to the pending queries it tracks.
380
+ let resetPendingQueries = (cs: t) => {
381
+ cs.fetchState = cs.fetchState->FetchState.resetPendingQueries
382
+ cs.pendingBudget = 0.
383
+ }
384
+
385
+ let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
386
+
387
+ // Block span over which a batch fully replaces the chain density estimate;
388
+ // smaller batches blend in proportionally, so a few sparse/dense blocks only
389
+ // nudge it.
390
+ let densityBlendWindow = 100.
391
+
392
+ // The last block this chain can fetch right now: the head, or endBlock when
393
+ // it's below the head.
394
+ let fetchCeiling = (cs: t) => {
395
+ let fetchState = cs.fetchState
396
+ switch fetchState.endBlock {
397
+ | Some(endBlock) => Pervasives.min(endBlock, fetchState.knownHeight)
398
+ | None => fetchState.knownHeight
399
+ }
400
+ }
401
+
402
+ // Events/block over the ready part of the buffer — a live signal that reacts
403
+ // to what fetching just found, unlike the processing EMA which only moves as
404
+ // batches commit.
405
+ let readyBufferDensity = (cs: t) => {
406
+ let readyCount = cs.fetchState->FetchState.bufferReadyCount
407
+ let span = cs.fetchState->FetchState.bufferBlockNumber - cs.processingBlockNumber
408
+ if readyCount > 0 && span > 0 {
409
+ Some(readyCount->Int.toFloat /. span->Int.toFloat)
410
+ } else {
411
+ None
412
+ }
413
+ }
414
+
415
+ // Density used for query sizing: the higher of the processing EMA and the
416
+ // ready-buffer density, so a stale-low EMA can't undersize queries while the
417
+ // buffer proves the range is dense. None means the chain is cold — no density
418
+ // signal at all.
419
+ let effectiveDensity = (cs: t) =>
420
+ switch (cs.chainDensity, cs->readyBufferDensity) {
421
+ | (Some(ema), Some(buffer)) => Some(Pervasives.max(ema, buffer))
422
+ | (Some(_) as density, None) | (None, Some(_) as density) => density
423
+ | (None, None) => None
424
+ }
425
+
426
+ // How far past the frontier a chain with no density signal targets while it
427
+ // takes its first measurements.
428
+ let coldTargetRange = 20_000
429
+
430
+ // This chain's share of the indexer-wide buffer budget turned into a soft
431
+ // target block: via density, or frontier + coldTargetRange when there's no
432
+ // density signal yet.
433
+ let targetBlock = (cs: t, ~chainTargetItems: float) => {
434
+ let fetchState = cs.fetchState
435
+ let fetchCeiling = cs->fetchCeiling
436
+ let bufferBlockNumber = fetchState->FetchState.bufferBlockNumber
437
+ switch cs->effectiveDensity {
438
+ | Some(density) if density > 0. =>
439
+ Pervasives.min(
440
+ fetchCeiling,
441
+ bufferBlockNumber + Math.ceil(chainTargetItems /. density)->Float.toInt,
442
+ )
443
+ | _ => Pervasives.min(bufferBlockNumber + coldTargetRange, fetchCeiling)
444
+ }
445
+ }
446
+
447
+ // Block range that cross-chain progress alignment maps fractions over: from
448
+ // the first block that can hold this chain's events to the last block it will
449
+ // fetch.
450
+ let progressRange = (cs: t) => {
451
+ let fetchState = cs.fetchState
452
+ let lower = fetchState.firstEventBlock->Option.getOr(fetchState.startBlock)
453
+ let upper = switch fetchState.endBlock {
454
+ | Some(endBlock) => endBlock
455
+ | None => fetchState.knownHeight
456
+ }
457
+ (lower, upper)
458
+ }
459
+
460
+ // A degenerate range (chain already at or past its last block) maps to 1 so it
461
+ // never constrains the other chains. Clamped at 0 for the initial -1 fetch
462
+ // frontier — the only possible blockNumber below the range's lower bound.
463
+ let progressAtBlock = (cs: t, ~blockNumber) => {
464
+ let (lower, upper) = cs->progressRange
465
+ upper <= lower
466
+ ? 1.
467
+ : Pervasives.max(
468
+ 0.,
469
+ Pervasives.min(1., (blockNumber - lower)->Int.toFloat /. (upper - lower)->Int.toFloat),
470
+ )
471
+ }
472
+
473
+ let blockAtProgress = (cs: t, ~progress) => {
474
+ let (lower, upper) = cs->progressRange
475
+ lower + Math.ceil(progress *. (upper - lower)->Int.toFloat)->Float.toInt
476
+ }
477
+
478
+ // Propose queries sized against this chain's target block. Called by
479
+ // CrossChainState's waterfall, furthest-behind chain first, with
480
+ // chainTargetItems set to whatever budget remains at that point and
481
+ // maxTargetBlock set to the most-behind chain's progress mapped onto this
482
+ // chain, so a chain with budget can't run further ahead than the chain the
483
+ // whole pool is prioritizing.
484
+ let getNextQuery = (
485
+ cs: t,
486
+ ~chainTargetItems: float,
487
+ ~chunkItemsMultiplier=1.,
488
+ ~maxTargetBlock=?,
489
+ ) => {
490
+ let chainTargetBlock = cs->targetBlock(~chainTargetItems)
491
+ let chainTargetBlock = switch maxTargetBlock {
492
+ | Some(maxTargetBlock) => Pervasives.min(chainTargetBlock, maxTargetBlock)
493
+ | None => chainTargetBlock
494
+ }
495
+ // When the target block is clamped (head/endBlock/cross-chain alignment) a
496
+ // known-density chain can't use the whole handed budget — cap the fresh part
497
+ // at what the clamped range actually costs (in-flight reservations stay on
498
+ // top: they're already accounted and shouldn't crowd out new partitions), so
499
+ // the waterfall's leftover flows to the next chain in the same tick instead
500
+ // of being held by an oversized probe.
501
+ let chainTargetItems = switch cs->effectiveDensity {
502
+ | Some(density) if density > 0. =>
503
+ // No extra headroom here: the budget is reserved in honest itemsEst units,
504
+ // and truncation safety lives in the itemsTarget server cap (sized with
505
+ // chunkItemsMultiplier at query creation) — multiplying the budget cap too
506
+ // would compound the two and hold budget away from other chains.
507
+ let rangeCost =
508
+ density *. (chainTargetBlock - cs.fetchState->FetchState.bufferBlockNumber)->Int.toFloat
509
+ Pervasives.min(chainTargetItems, Math.ceil(rangeCost) +. cs.pendingBudget)
510
+ // No density signal: the cross-chain waterfall already clamped the handed
511
+ // budget to the cold-chain cap, so it's used as-is.
512
+ | _ => chainTargetItems
513
+ }
514
+ cs.fetchState->FetchState.getNextQuery(
515
+ ~chainTargetBlock,
516
+ ~chainTargetItems,
517
+ ~chunkItemsMultiplier,
518
+ )
519
+ }
520
+
521
+ // Run a fetch tick for this chain against its sources, feeding the owned fetch
522
+ // frontier to the source manager.
523
+ let dispatch = (
524
+ cs: t,
525
+ ~executeQuery,
526
+ ~waitForNewBlock,
527
+ ~onNewBlock,
528
+ ~action: FetchState.nextQuery,
529
+ ~stateId,
530
+ ) =>
531
+ cs.sourceManager->SourceManager.dispatch(
532
+ ~fetchState=cs.fetchState,
533
+ ~executeQuery,
534
+ ~waitForNewBlock,
535
+ ~onNewBlock,
536
+ ~action,
537
+ ~stateId,
538
+ )
539
+
385
540
  // --- Derived (pure). ---
386
541
 
387
542
  let hasProcessedToEndblock = (cs: t) => {
@@ -399,16 +554,201 @@ let getHighestBlockBelowThreshold = (cs: t): int => {
399
554
 
400
555
  let isActivelyIndexing = (cs: t) => cs.fetchState->FetchState.isActivelyIndexing
401
556
 
402
- let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
403
-
404
557
  // True once the fetch frontier has reached the head/endBlock for this chain.
405
558
  let isFetchingAtHead = (cs: t) => cs.fetchState->FetchState.isFetchingAtHead
406
559
 
560
+ // Reached head on a chain with no configured endBlock — used by auto-exit to
561
+ // detect that no events were found in the start..head range.
562
+ let isAtHeadWithoutEndBlock = (cs: t) =>
563
+ cs.isProgressAtHead && cs.fetchState.endBlock->Option.isNone
564
+
407
565
  // --- State transitions. The chain state is mutated only through these; each
408
566
  // owns a cohesive update so callers don't juggle individual fields. ---
409
567
 
410
- // Apply a fetch response: register any new dynamic contracts, append the items
411
- // to the buffer and advance the known head.
568
+ // Per-store grouping data a single pass over `items` produces (see
569
+ // `groupBatchItems`), fed into `TransactionStore`/`BlockStore`'s `materialize`.
570
+ type transactionGroups = {
571
+ txBlockNumbers: array<int>,
572
+ transactionIndices: array<int>,
573
+ transactionMasks: array<float>,
574
+ payloadGroups: array<array<Internal.eventPayload>>,
575
+ anyTransactionFieldSelected: bool,
576
+ }
577
+ type blockGroups = {
578
+ blockBlockNumbers: array<int>,
579
+ blockMasks: array<float>,
580
+ blockItemGroups: array<array<Internal.eventItem>>,
581
+ }
582
+
583
+ // Walk `items` once to build both the transaction-store and block-store
584
+ // grouping data, instead of `TransactionStore`/`BlockStore` each re-walking the
585
+ // same batch independently. Items already arrive in (blockNumber, logIndex)
586
+ // order, so a (blockNumber, transactionIndex) run and a blockNumber-only run
587
+ // each stay adjacent; every item is checked against both, in the same pass.
588
+ // `includeBlocks` skips the block side entirely for Fuel, which carries the
589
+ // block inline and has no store.
590
+ let groupBatchItems = (items: array<Internal.item>, ~includeBlocks: bool): (
591
+ transactionGroups,
592
+ blockGroups,
593
+ ) => {
594
+ let txBlockNumbers = []
595
+ let transactionIndices = []
596
+ let transactionMasks = []
597
+ let payloadGroups = []
598
+ let anyTransactionFieldSelected = ref(false)
599
+
600
+ let blockBlockNumbers = []
601
+ let blockMasks = []
602
+ let blockItemGroups = []
603
+
604
+ items->Array.forEach(item =>
605
+ switch item {
606
+ | Internal.Event(_) =>
607
+ let eventItem = item->Internal.castUnsafeEventItem
608
+ let {blockNumber} = eventItem
609
+
610
+ switch eventItem.payload->Internal.getPayloadTransaction->Nullable.toOption {
611
+ | Some(_) => () // RPC/simulate/Fuel carry the transaction inline.
612
+ | None =>
613
+ let {transactionIndex} = eventItem
614
+ let mask = eventItem.onEventRegistration.eventConfig.transactionFieldMask
615
+ if mask != 0. {
616
+ anyTransactionFieldSelected := true
617
+ }
618
+ let last = payloadGroups->Array.length - 1
619
+ if (
620
+ last >= 0 &&
621
+ txBlockNumbers->Array.getUnsafe(last) == blockNumber &&
622
+ transactionIndices->Array.getUnsafe(last) == transactionIndex
623
+ ) {
624
+ payloadGroups->Array.getUnsafe(last)->Array.push(eventItem.payload)
625
+ transactionMasks->Array.setUnsafe(
626
+ last,
627
+ FieldMask.orMask(transactionMasks->Array.getUnsafe(last), mask),
628
+ )
629
+ } else {
630
+ txBlockNumbers->Array.push(blockNumber)
631
+ transactionIndices->Array.push(transactionIndex)
632
+ transactionMasks->Array.push(mask)
633
+ payloadGroups->Array.push([eventItem.payload])
634
+ }
635
+ }
636
+
637
+ if includeBlocks {
638
+ switch eventItem.payload->Internal.getPayloadBlock->Nullable.toOption {
639
+ | Some(_) => () // RPC/simulate/Fuel carry the block inline.
640
+ | None =>
641
+ let mask = eventItem.onEventRegistration.eventConfig.blockFieldMask
642
+ let last = blockItemGroups->Array.length - 1
643
+ if last >= 0 && blockBlockNumbers->Array.getUnsafe(last) == blockNumber {
644
+ blockItemGroups->Array.getUnsafe(last)->Array.push(eventItem)
645
+ blockMasks->Array.setUnsafe(
646
+ last,
647
+ FieldMask.orMask(blockMasks->Array.getUnsafe(last), mask),
648
+ )
649
+ } else {
650
+ blockBlockNumbers->Array.push(blockNumber)
651
+ blockMasks->Array.push(mask)
652
+ blockItemGroups->Array.push([eventItem])
653
+ }
654
+ }
655
+ }
656
+ | Internal.Block(_) => ()
657
+ }
658
+ )
659
+
660
+ (
661
+ {
662
+ txBlockNumbers,
663
+ transactionIndices,
664
+ transactionMasks,
665
+ payloadGroups,
666
+ anyTransactionFieldSelected: anyTransactionFieldSelected.contents,
667
+ },
668
+ {blockBlockNumbers, blockMasks, blockItemGroups},
669
+ )
670
+ }
671
+
672
+ // Materialise a `TransactionStore` against precomputed groups (see
673
+ // `groupBatchItems`). Store-backed items always get a transaction object — the
674
+ // selected fields, or `{}` when nothing was selected — so `event.transaction`
675
+ // is never `undefined` (matching the inline sources).
676
+ let applyTransactionGroups = async (store: TransactionStore.t, g: transactionGroups) => {
677
+ if g.payloadGroups->Utils.Array.notEmpty {
678
+ if g.anyTransactionFieldSelected {
679
+ let txs = await store->TransactionStore.materialize(
680
+ ~blockNumbers=g.txBlockNumbers,
681
+ ~transactionIndices=g.transactionIndices,
682
+ ~masks=g.transactionMasks,
683
+ )
684
+ g.payloadGroups->Array.forEachWithIndex((payloads, i) => {
685
+ let tx = txs->Array.getUnsafe(i)
686
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(tx))
687
+ })
688
+ } else {
689
+ g.payloadGroups->Array.forEach(payloads =>
690
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(%raw(`{}`)))
691
+ )
692
+ }
693
+ }
694
+ }
695
+
696
+ // Materialise a `BlockStore` against precomputed groups (see `groupBatchItems`).
697
+ let applyBlockGroups = async (store: BlockStore.t, g: blockGroups) => {
698
+ if g.blockItemGroups->Utils.Array.notEmpty {
699
+ let blocks = await store->BlockStore.materialize(
700
+ ~blockNumbers=g.blockBlockNumbers,
701
+ ~masks=g.blockMasks,
702
+ )
703
+ g.blockItemGroups->Array.forEachWithIndex((group, i) => {
704
+ let block = blocks->Array.getUnsafe(i)
705
+ group->Array.forEach(ei => ei.payload->Internal.setPayloadBlock(block))
706
+ })
707
+ }
708
+ }
709
+
710
+ let includeBlocksForEcosystem = (ecosystem: Ecosystem.name) =>
711
+ switch ecosystem {
712
+ | Evm | Svm => true
713
+ | Fuel => false
714
+ }
715
+
716
+ // Materialise the chain stores' selected transaction and block fields onto a
717
+ // batch's items at batch prep (the persistent-store path). A single pass over
718
+ // `items` (`groupBatchItems`) builds both stores' selection masks before the
719
+ // two independent materialize calls run concurrently.
720
+ let materializeBatchItems = async (cs: t, ~items: array<Internal.item>, ~ecosystem) => {
721
+ let (txGroups, blockGroups) =
722
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
723
+ let _ = await Promise.all2((
724
+ cs.transactionStore->applyTransactionGroups(txGroups),
725
+ cs.blockStore->applyBlockGroups(blockGroups),
726
+ ))
727
+ }
728
+
729
+ // Materialise a fetch-response page's transactions and blocks onto its items
730
+ // before contract-register handlers read them. `None` pages (RPC/Fuel/Simulate
731
+ // keep them inline) are a no-op.
732
+ let materializePageItems = async (
733
+ ~items: array<Internal.item>,
734
+ ~transactionStore: option<TransactionStore.t>,
735
+ ~blockStore: option<BlockStore.t>,
736
+ ~ecosystem,
737
+ ) => {
738
+ let (txGroups, blockGroups) =
739
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
740
+ let _ = await Promise.all2((
741
+ switch transactionStore {
742
+ | Some(store) => store->applyTransactionGroups(txGroups)
743
+ | None => Promise.resolve()
744
+ },
745
+ switch blockStore {
746
+ | Some(store) => store->applyBlockGroups(blockGroups)
747
+ | None => Promise.resolve()
748
+ },
749
+ ))
750
+ }
751
+
412
752
  let handleQueryResult = (
413
753
  cs: t,
414
754
  ~query: FetchState.query,
@@ -416,16 +756,41 @@ let handleQueryResult = (
416
756
  ~newItemsWithDcs,
417
757
  ~latestFetchedBlock,
418
758
  ~knownHeight,
759
+ ~transactionStore as txPage: option<TransactionStore.t>,
760
+ ~blockStore as blockPage: option<BlockStore.t>,
419
761
  ) => {
762
+ // Merge this response's pages into the chain stores in lockstep with appending
763
+ // its items to the buffer. Inline sources contribute no page.
764
+ switch txPage {
765
+ | Some(page) => cs.transactionStore->TransactionStore.merge(page)
766
+ | None => ()
767
+ }
768
+ switch blockPage {
769
+ | Some(page) => cs.blockStore->BlockStore.merge(page)
770
+ | None => ()
771
+ }
772
+
420
773
  let fs = switch newItemsWithDcs {
421
774
  | [] => cs.fetchState
422
- | _ => cs.fetchState->FetchState.registerDynamicContracts(newItemsWithDcs)
775
+ | _ =>
776
+ cs.fetchState->FetchState.registerDynamicContracts(
777
+ ~indexingAddresses=cs.indexingAddresses,
778
+ newItemsWithDcs,
779
+ )
423
780
  }
424
781
 
425
782
  cs.fetchState =
426
783
  fs
427
- ->FetchState.handleQueryResult(~query, ~latestFetchedBlock, ~newItems)
784
+ ->FetchState.handleQueryResult(
785
+ ~indexingAddresses=cs.indexingAddresses,
786
+ ~query,
787
+ ~latestFetchedBlock,
788
+ ~newItems,
789
+ )
428
790
  ->FetchState.updateKnownHeight(~knownHeight)
791
+
792
+ // The query is no longer in flight, so release its reservation.
793
+ cs.pendingBudget = Pervasives.max(0., cs.pendingBudget -. query.itemsEst->Int.toFloat)
429
794
  }
430
795
 
431
796
  // Run reorg detection against a fetch response and commit the updated guard.
@@ -447,7 +812,7 @@ let prepareReorg = (cs: t, ~eventsProcessedDiff) => {
447
812
  | Some(diff) => cs.numEventsProcessed = cs.numEventsProcessed +. diff
448
813
  | None => ()
449
814
  }
450
- cs.fetchState = cs.fetchState->FetchState.resetPendingQueries
815
+ cs->resetPendingQueries
451
816
  }
452
817
 
453
818
  let updateKnownHeight = (cs: t, ~knownHeight) =>
@@ -466,6 +831,56 @@ let setEndBlockToFirstEvent = (cs: t, ~blockNumber) =>
466
831
  let enterReorgThreshold = (cs: t) =>
467
832
  cs.fetchState = cs.fetchState->FetchState.updateInternal(~blockLag=cs.chainConfig.blockLag)
468
833
 
834
+ // Snapshot the chain's metadata fields for staging into the chains table.
835
+ let toChainMetadata = (cs: t): InternalTable.Chains.metaFields => {
836
+ firstEventBlockNumber: cs.fetchState.firstEventBlock->Null.fromOption,
837
+ isHyperSync: (cs.sourceManager->SourceManager.getActiveSource).poweredByHyperSync,
838
+ latestFetchedBlockNumber: cs.fetchState->FetchState.bufferBlockNumber,
839
+ timestampCaughtUpToHeadOrEndblock: cs.timestampCaughtUpToHeadOrEndblock->Null.fromOption,
840
+ }
841
+
842
+ // Snapshot the chain's view for the status API.
843
+ let toChainData = (cs: t): chainData => {
844
+ chainId: cs.chainConfig.id->Int.toFloat,
845
+ poweredByHyperSync: (cs.sourceManager->SourceManager.getActiveSource).poweredByHyperSync,
846
+ firstEventBlockNumber: cs.fetchState.firstEventBlock,
847
+ latestProcessedBlock: cs.committedProgressBlockNumber === -1
848
+ ? None
849
+ : Some(cs.committedProgressBlockNumber),
850
+ timestampCaughtUpToHeadOrEndblock: cs.timestampCaughtUpToHeadOrEndblock,
851
+ numEventsProcessed: cs.numEventsProcessed,
852
+ latestFetchedBlockNumber: Pervasives.max(cs.fetchState->FetchState.bufferBlockNumber, 0),
853
+ knownHeight: cs->hasProcessedToEndblock
854
+ ? cs.fetchState.endBlock->Option.getOr(cs.fetchState.knownHeight)
855
+ : cs.fetchState.knownHeight,
856
+ numBatchesFetched: 0,
857
+ startBlock: cs.fetchState.startBlock,
858
+ endBlock: cs.fetchState.endBlock,
859
+ numAddresses: cs.indexingAddresses->IndexingAddresses.size,
860
+ }
861
+
862
+ // Snapshot the inputs a batch build needs from this chain.
863
+ let toChainBeforeBatch = (cs: t): Batch.chainBeforeBatch => {
864
+ fetchState: cs.fetchState,
865
+ progressBlockNumber: cs.committedProgressBlockNumber,
866
+ totalEventsProcessed: cs.numEventsProcessed,
867
+ sourceBlockNumber: cs.fetchState.knownHeight,
868
+ reorgDetection: cs.reorgDetection,
869
+ chainConfig: cs.chainConfig,
870
+ }
871
+
872
+ // Whether the chain's post-batch fetch frontier is ready to cross into the reorg
873
+ // threshold, using the batch's progressed frontier when this chain advanced.
874
+ let isReadyToEnterReorgThresholdAfterBatch = (cs: t, ~batch: Batch.t) => {
875
+ let fetchState = switch batch.progressedChainsById->Utils.Dict.dangerouslyGetByIntNonOption(
876
+ cs.fetchState.chainId,
877
+ ) {
878
+ | Some(chainAfterBatch) => chainAfterBatch.fetchState
879
+ | None => cs.fetchState
880
+ }
881
+ fetchState->FetchState.isReadyToEnterReorgThreshold
882
+ }
883
+
469
884
  // Commit the post-batch fetch frontier for a chain that progressed in the batch,
470
885
  // applying blockLag when this batch also crosses into the reorg threshold.
471
886
  let advanceAfterBatch = (cs: t, ~batch: Batch.t, ~enteringReorgThreshold) =>
@@ -476,14 +891,19 @@ let advanceAfterBatch = (cs: t, ~batch: Batch.t, ~enteringReorgThreshold) =>
476
891
  cs.fetchState = enteringReorgThreshold
477
892
  ? chainAfterBatch.fetchState->FetchState.updateInternal(~blockLag=cs.chainConfig.blockLag)
478
893
  : chainAfterBatch.fetchState
894
+
895
+ // The batch's items just left the buffer, so the remaining buffer's span
896
+ // starts at the batch's progress.
897
+ cs.processingBlockNumber = chainAfterBatch.progressBlockNumber
479
898
  | None => ()
480
899
  }
481
900
 
482
901
  // Commit a processed batch's progress for this chain (progress block, events
483
902
  // processed, head/safe-checkpoint tracking, first event block). Emits the
484
903
  // per-chain progress metrics. Readiness is decided by CrossChainState once every
485
- // chain is caught up (see markReady).
486
- let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
904
+ // chain is caught up (see markReady). `blockTimestampName` is the ecosystem's
905
+ // block-timestamp field, read off the payload block for the latency metric.
906
+ let applyBatchProgress = (cs: t, ~batch: Batch.t, ~blockTimestampName: string) => {
487
907
  let chainId = cs.chainConfig.id
488
908
 
489
909
  switch batch.progressedChainsById->Utils.Dict.dangerouslyGetByIntNonOption(chainId) {
@@ -501,15 +921,27 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
501
921
  )
502
922
  }
503
923
 
504
- // Calculate and set latency metrics
505
- switch batch->Batch.findLastEventItem(~chainId) {
506
- | Some(eventItem) => {
507
- let blockTimestampMs = eventItem.timestamp * 1000
508
- Prometheus.ProgressLatency.set(
509
- ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
510
- ~chainId,
511
- )
512
- }
924
+ // Calculate and set latency metrics. The payload block is materialised or
925
+ // inline by processing time; its timestamp may still be absent (e.g. an
926
+ // SVM slot with no block row) the metric is skipped then.
927
+ switch batch
928
+ ->Batch.findLastEventItem(~chainId)
929
+ ->Option.flatMap(eventItem =>
930
+ eventItem.payload
931
+ ->Internal.getPayloadBlock
932
+ ->Nullable.toOption
933
+ )
934
+ ->Option.flatMap(block =>
935
+ block
936
+ ->(Utils.magic: Internal.eventBlock => dict<unknown>)
937
+ ->Utils.Dict.dangerouslyGetNonOption(blockTimestampName)
938
+ ) {
939
+ | Some(blockTimestamp) =>
940
+ let blockTimestampMs = blockTimestamp->(Utils.magic: unknown => int) * 1000
941
+ Prometheus.ProgressLatency.set(
942
+ ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
943
+ ~chainId,
944
+ )
513
945
  | None => ()
514
946
  }
515
947
 
@@ -524,8 +956,42 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
524
956
  }
525
957
  }
526
958
 
959
+ // Chain-wide density update: seed with the batch's own events/block on
960
+ // the first update, then blend weighted by the batch's block span — a
961
+ // few sparse/dense blocks barely nudge the estimate, while a
962
+ // window-sized batch replaces it.
963
+ let deltaBlocks = chainAfterBatch.progressBlockNumber - cs.committedProgressBlockNumber
964
+ if deltaBlocks > 0 {
965
+ let deltaEvents = chainAfterBatch.totalEventsProcessed -. cs.numEventsProcessed
966
+ // Don't seed a density before the first event is seen — a progress-only
967
+ // batch would otherwise set it to 0, matching the resume-seed guard.
968
+ switch (cs.chainDensity, deltaEvents > 0.) {
969
+ | (None, false) => ()
970
+ | _ =>
971
+ let batchDensity = deltaEvents /. deltaBlocks->Int.toFloat
972
+ cs.chainDensity = Some(
973
+ switch cs.chainDensity {
974
+ | None => batchDensity
975
+ | Some(oldDensity) =>
976
+ let alpha = Pervasives.min(1., deltaBlocks->Int.toFloat /. densityBlendWindow)
977
+ oldDensity *. (1. -. alpha) +. batchDensity *. alpha
978
+ },
979
+ )
980
+ }
981
+ }
982
+
527
983
  cs.committedProgressBlockNumber = chainAfterBatch.progressBlockNumber
984
+
985
+ // Normally already set by advanceAfterBatch at batch creation; catch up
986
+ // here for paths that commit progress without it.
987
+ cs.processingBlockNumber = Pervasives.max(
988
+ cs.processingBlockNumber,
989
+ chainAfterBatch.progressBlockNumber,
990
+ )
528
991
  cs.numEventsProcessed = chainAfterBatch.totalEventsProcessed
992
+ // Processed blocks' transactions and blocks are no longer needed.
993
+ cs.transactionStore->TransactionStore.prune(chainAfterBatch.progressBlockNumber)
994
+ cs.blockStore->BlockStore.prune(chainAfterBatch.progressBlockNumber)
529
995
  cs.isProgressAtHead = cs.isProgressAtHead || chainAfterBatch.isProgressAtHeadWhenBatchCreated
530
996
  switch cs.safeCheckpointTracking {
531
997
  | Some(safeCheckpointTracking) =>
@@ -598,8 +1064,15 @@ let rollback = (
598
1064
  )
599
1065
  | None => ()
600
1066
  }
601
- cs.fetchState = cs.fetchState->FetchState.rollback(~targetBlockNumber=newProgressBlockNumber)
1067
+ cs.fetchState =
1068
+ cs.fetchState->FetchState.rollback(
1069
+ ~indexingAddresses=cs.indexingAddresses,
1070
+ ~targetBlockNumber=newProgressBlockNumber,
1071
+ )
1072
+ cs.transactionStore->TransactionStore.rollback(newProgressBlockNumber)
1073
+ cs.blockStore->BlockStore.rollback(newProgressBlockNumber)
602
1074
  cs.committedProgressBlockNumber = newProgressBlockNumber
1075
+ cs.processingBlockNumber = newProgressBlockNumber
603
1076
  cs.numEventsProcessed = newTotalEventsProcessed
604
1077
  | None =>
605
1078
  if isReorgChain {
@@ -608,11 +1081,17 @@ let rollback = (
608
1081
  ~blockNumber=rollbackTargetBlockNumber,
609
1082
  )
610
1083
  cs.fetchState =
611
- cs.fetchState->FetchState.rollback(~targetBlockNumber=rollbackTargetBlockNumber)
1084
+ cs.fetchState->FetchState.rollback(
1085
+ ~indexingAddresses=cs.indexingAddresses,
1086
+ ~targetBlockNumber=rollbackTargetBlockNumber,
1087
+ )
1088
+ cs.transactionStore->TransactionStore.rollback(rollbackTargetBlockNumber)
1089
+ cs.blockStore->BlockStore.rollback(rollbackTargetBlockNumber)
612
1090
  cs.committedProgressBlockNumber = Pervasives.min(
613
1091
  cs.committedProgressBlockNumber,
614
1092
  rollbackTargetBlockNumber,
615
1093
  )
1094
+ cs.processingBlockNumber = Pervasives.min(cs.processingBlockNumber, rollbackTargetBlockNumber)
616
1095
  }
617
1096
  }
618
1097
  }