envio 3.3.0-alpha.7 → 3.3.0-alpha.9

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 (95) hide show
  1. package/index.d.ts +23 -5
  2. package/package.json +6 -6
  3. package/src/Api.res +1 -1
  4. package/src/Api.res.mjs +1 -1
  5. package/src/ChainFetching.res +21 -10
  6. package/src/ChainFetching.res.mjs +11 -10
  7. package/src/ChainState.res +401 -171
  8. package/src/ChainState.res.mjs +270 -104
  9. package/src/ChainState.resi +19 -7
  10. package/src/Config.res +11 -27
  11. package/src/Config.res.mjs +8 -7
  12. package/src/Core.res +7 -0
  13. package/src/CrossChainState.res +82 -82
  14. package/src/CrossChainState.res.mjs +52 -59
  15. package/src/CrossChainState.resi +1 -1
  16. package/src/Ecosystem.res +3 -3
  17. package/src/Ecosystem.res.mjs +3 -3
  18. package/src/Envio.res +14 -9
  19. package/src/EnvioGlobal.res +53 -0
  20. package/src/EnvioGlobal.res.mjs +31 -0
  21. package/src/EventConfigBuilder.res +117 -69
  22. package/src/EventConfigBuilder.res.mjs +71 -34
  23. package/src/EventProcessing.res +19 -15
  24. package/src/EventProcessing.res.mjs +13 -12
  25. package/src/FetchState.res +410 -185
  26. package/src/FetchState.res.mjs +268 -253
  27. package/src/HandlerLoader.res +8 -113
  28. package/src/HandlerLoader.res.mjs +2 -88
  29. package/src/HandlerRegister.res +518 -144
  30. package/src/HandlerRegister.res.mjs +285 -133
  31. package/src/HandlerRegister.resi +24 -8
  32. package/src/IndexerState.res +6 -3
  33. package/src/IndexerState.res.mjs +3 -3
  34. package/src/IndexerState.resi +1 -1
  35. package/src/IndexingAddresses.res +10 -7
  36. package/src/IndexingAddresses.res.mjs +8 -7
  37. package/src/IndexingAddresses.resi +3 -1
  38. package/src/Internal.res +104 -39
  39. package/src/Internal.res.mjs +11 -1
  40. package/src/LogSelection.res +102 -165
  41. package/src/LogSelection.res.mjs +101 -116
  42. package/src/Main.res +49 -164
  43. package/src/Main.res.mjs +39 -104
  44. package/src/Metrics.res +43 -2
  45. package/src/Metrics.res.mjs +39 -3
  46. package/src/Prometheus.res +0 -35
  47. package/src/Prometheus.res.mjs +33 -68
  48. package/src/RawEvent.res +7 -2
  49. package/src/RawEvent.res.mjs +4 -4
  50. package/src/RollbackCommit.res +4 -1
  51. package/src/RollbackCommit.res.mjs +3 -2
  52. package/src/SimulateItems.res +20 -7
  53. package/src/SimulateItems.res.mjs +7 -11
  54. package/src/TestIndexer.res +1 -1
  55. package/src/TestIndexer.res.mjs +1 -1
  56. package/src/sources/BlockStore.res +46 -0
  57. package/src/sources/BlockStore.res.mjs +24 -0
  58. package/src/sources/EventRouter.res +19 -12
  59. package/src/sources/EventRouter.res.mjs +7 -5
  60. package/src/sources/Evm.res +58 -5
  61. package/src/sources/Evm.res.mjs +44 -5
  62. package/src/sources/EvmChain.res +14 -18
  63. package/src/sources/EvmChain.res.mjs +12 -13
  64. package/src/sources/FieldMask.res +39 -0
  65. package/src/sources/FieldMask.res.mjs +42 -0
  66. package/src/sources/Fuel.res +8 -3
  67. package/src/sources/Fuel.res.mjs +5 -4
  68. package/src/sources/HyperFuelSource.res +31 -40
  69. package/src/sources/HyperFuelSource.res.mjs +52 -44
  70. package/src/sources/HyperSync.res +31 -9
  71. package/src/sources/HyperSync.res.mjs +47 -31
  72. package/src/sources/HyperSync.resi +10 -3
  73. package/src/sources/HyperSyncClient.res +15 -4
  74. package/src/sources/HyperSyncHeightStream.res +1 -8
  75. package/src/sources/HyperSyncHeightStream.res.mjs +0 -2
  76. package/src/sources/HyperSyncSource.res +58 -90
  77. package/src/sources/HyperSyncSource.res.mjs +57 -66
  78. package/src/sources/RpcSource.res +118 -120
  79. package/src/sources/RpcSource.res.mjs +92 -74
  80. package/src/sources/RpcWebSocketHeightStream.res +0 -5
  81. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -2
  82. package/src/sources/SimulateSource.res +5 -3
  83. package/src/sources/SimulateSource.res.mjs +12 -4
  84. package/src/sources/Source.res +19 -5
  85. package/src/sources/SourceManager.res +60 -10
  86. package/src/sources/SourceManager.res.mjs +54 -8
  87. package/src/sources/SourceManager.resi +10 -0
  88. package/src/sources/Svm.res +14 -10
  89. package/src/sources/Svm.res.mjs +23 -6
  90. package/src/sources/SvmHyperSyncClient.res +5 -6
  91. package/src/sources/SvmHyperSyncSource.res +82 -41
  92. package/src/sources/SvmHyperSyncSource.res.mjs +86 -41
  93. package/src/sources/TransactionStore.res +6 -107
  94. package/src/sources/TransactionStore.res.mjs +5 -86
  95. package/svm.schema.json +19 -0
@@ -14,16 +14,24 @@ type t = {
14
14
  mutable timestampCaughtUpToHeadOrEndblock: option<Date.t>,
15
15
  mutable committedProgressBlockNumber: int,
16
16
  mutable numEventsProcessed: float,
17
- // Running sum of in-flight queries' estResponseSize, kept here so the
17
+ // Running sum of in-flight queries' itemsTarget, kept here so the
18
18
  // scheduler doesn't re-sum pending queries on every tick. Incremented when
19
19
  // queries are dispatched, decremented as their responses land.
20
20
  mutable pendingBudget: float,
21
+ // Chain-wide events/block, used to turn a chain's item budget into a target
22
+ // block for query sizing. Seeded from cumulative progress on construction,
23
+ // then smoothed with an EMA on every batch (see applyBatchProgress). None
24
+ // until the chain has processed at least one event.
25
+ mutable chainDensity: option<float>,
21
26
  mutable reorgDetection: ReorgDetection.t,
22
27
  mutable safeCheckpointTracking: option<SafeCheckpointTracking.t>,
23
28
  // Holds this chain's transactions (kept in Rust) keyed by (blockNumber,
24
29
  // transactionIndex). Fetch responses merge their page in; entries are pruned
25
30
  // as the chain progresses and dropped above the target on rollback.
26
31
  transactionStore: TransactionStore.t,
32
+ // Holds this chain's blocks (kept in Rust) keyed by block number. Same merge /
33
+ // prune / rollback lifecycle as the transaction store.
34
+ blockStore: BlockStore.t,
27
35
  }
28
36
 
29
37
  // Per-chain shape returned by the status API.
@@ -70,6 +78,8 @@ let make = (
70
78
  ~timestampCaughtUpToHeadOrEndblock=None,
71
79
  ~isProgressAtHead=false,
72
80
  ~transactionStore=TransactionStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
81
+ ~chainDensity=None,
82
+ ~blockStore=BlockStore.make(~ecosystem=Ecosystem.Evm, ~shouldChecksum=false),
73
83
  ~logger: Pino.t,
74
84
  ): t => {
75
85
  logger,
@@ -82,9 +92,11 @@ let make = (
82
92
  committedProgressBlockNumber,
83
93
  numEventsProcessed,
84
94
  pendingBudget: 0.,
95
+ chainDensity,
85
96
  reorgDetection,
86
97
  safeCheckpointTracking,
87
98
  transactionStore,
99
+ blockStore,
88
100
  }
89
101
 
90
102
  let makeInternal = (
@@ -95,7 +107,7 @@ let makeInternal = (
95
107
  ~firstEventBlock=None,
96
108
  ~progressBlockNumber,
97
109
  ~config: Config.t,
98
- ~registrations: HandlerRegister.registrations,
110
+ ~registrationsByChainId: HandlerRegister.registrationsByChainId,
99
111
  ~logger,
100
112
  ~timestampCaughtUpToHeadOrEndblock,
101
113
  ~numEventsProcessed,
@@ -106,123 +118,25 @@ let makeInternal = (
106
118
  ~knownHeight=0,
107
119
  ~reducedPollingInterval=?,
108
120
  ): t => {
109
- // We don't need the router itself, but only validation logic,
110
- // since now event router is created for selection of events
111
- // and validation doesn't work correctly in routers.
112
- // Ideally to split it into two different parts.
113
- let eventRouter = EventRouter.empty()
114
-
115
- // Aggregate events we want to fetch
116
- let eventConfigs: array<Internal.eventConfig> = []
117
-
118
- let notRegisteredEvents = []
121
+ // Handler binding + `where`-derived fetch state, and onBlock registrations,
122
+ // are already collected by `HandlerRegister.finishRegistration`, keyed by
123
+ // chain - this just looks up this chain's slice.
124
+ let {onEventRegistrations, onBlockRegistrations} =
125
+ registrationsByChainId
126
+ ->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
127
+ ->Option.getOr({onEventRegistrations: [], onBlockRegistrations: []})
119
128
 
120
129
  chainConfig.contracts->Array.forEach(contract => {
121
- let contractName = contract.name
122
-
123
- contract.events->Array.forEach(eventConfig => {
124
- let {isWildcard} = eventConfig
125
- let hasContractRegister = eventConfig.contractRegister->Option.isSome
126
-
127
- // Should validate the events
128
- eventRouter->EventRouter.addOrThrow(
129
- eventConfig.id,
130
- (),
131
- ~contractName,
132
- ~chain=ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id),
133
- ~eventName=eventConfig.name,
134
- ~isWildcard,
135
- )
136
-
137
- // Filter out non-preRegistration events on preRegistration phase
138
- // so we don't care about it in fetch state and workers anymore
139
- let shouldBeIncluded = if config.enableRawEvents {
140
- true
141
- } else {
142
- let isRegistered = hasContractRegister || eventConfig.handler->Option.isSome
143
- if !isRegistered {
144
- notRegisteredEvents->Array.push(eventConfig)
145
- }
146
- isRegistered
147
- }
148
-
149
- // Check if event has Static([]) filters (from a dynamic where
150
- // callback returning `false` / SkipAll for this chain).
151
- // If so, skip it entirely - it should never be fetched
152
- let shouldSkip = try {
153
- let getEventFiltersOrThrow = (
154
- eventConfig->(Utils.magic: Internal.eventConfig => Internal.evmEventConfig)
155
- ).getEventFiltersOrThrow
156
-
157
- // Check for non-evm chains
158
- if (
159
- getEventFiltersOrThrow->(Utils.magic: (ChainMap.Chain.t => Internal.eventFilters) => bool)
160
- ) {
161
- switch getEventFiltersOrThrow(ChainMap.Chain.makeUnsafe(~chainId=chainConfig.id)) {
162
- | Static([]) => true
163
- | _ => false
164
- }
165
- } else {
166
- false
167
- }
168
- } catch {
169
- // Can throw when filter is invalid
170
- // Don't skip an event in this case. Let it throw in a better place - source code
171
- | _ => false
172
- }
173
-
174
- if shouldBeIncluded && !shouldSkip {
175
- eventConfigs->Array.push(eventConfig)
176
- }
177
- })
178
-
179
130
  switch contract.startBlock {
180
131
  | Some(startBlock) if startBlock < chainConfig.startBlock =>
181
132
  JsError.throwWithMessage(
182
- `The start block for contract "${contractName}" is less than the chain start block. This is not supported yet.`,
133
+ `The start block for contract "${contract.name}" is less than the chain start block. This is not supported yet.`,
183
134
  )
184
135
  | _ => ()
185
136
  }
186
137
  })
187
138
 
188
- if notRegisteredEvents->Utils.Array.notEmpty {
189
- logger->Logging.childInfo(
190
- `The event${if notRegisteredEvents->Array.length > 1 {
191
- "s"
192
- } else {
193
- ""
194
- }} ${notRegisteredEvents
195
- ->Array.map(eventConfig => `${eventConfig.contractName}.${eventConfig.name}`)
196
- ->Array.joinUnsafe(", ")} don't have an event handler and skipped for indexing.`,
197
- )
198
- }
199
-
200
- let onBlockConfigs =
201
- registrations.onBlockByChainId->Utils.Dict.dangerouslyGetNonOption(chainConfig.id->Int.toString)
202
- switch onBlockConfigs {
203
- | Some(onBlockConfigs) =>
204
- // TODO: Move it to the HandlerRegister module
205
- // so the error is thrown with better stack trace
206
- onBlockConfigs->Array.forEach(onBlockConfig => {
207
- if onBlockConfig.startBlock->Option.getOr(startBlock) < startBlock {
208
- JsError.throwWithMessage(
209
- `The start block for onBlock handler "${onBlockConfig.name}" is less than the chain start block (${startBlock->Int.toString}). This is not supported yet.`,
210
- )
211
- }
212
- switch endBlock {
213
- | Some(chainEndBlock) =>
214
- if onBlockConfig.endBlock->Option.getOr(chainEndBlock) > chainEndBlock {
215
- JsError.throwWithMessage(
216
- `The end block for onBlock handler "${onBlockConfig.name}" is greater than the chain end block (${chainEndBlock->Int.toString}). This is not supported yet.`,
217
- )
218
- }
219
- | None => ()
220
- }
221
- })
222
- | None => ()
223
- }
224
-
225
- let contractConfigs = IndexingAddresses.makeContractConfigs(~eventConfigs)
139
+ let contractConfigs = IndexingAddresses.makeContractConfigs(~onEventRegistrations)
226
140
  let indexingAddressIndex = IndexingAddresses.make(~contractConfigs, ~addresses=indexingAddresses)
227
141
 
228
142
  let fetchState = FetchState.make(
@@ -232,7 +146,7 @@ let makeInternal = (
232
146
  ~progressBlockNumber,
233
147
  ~startBlock,
234
148
  ~endBlock,
235
- ~eventConfigs,
149
+ ~onEventRegistrations,
236
150
  ~maxOnBlockBufferSize=2 * config.batchSize,
237
151
  ~knownHeight,
238
152
  ~chainId=chainConfig.id,
@@ -241,7 +155,7 @@ let makeInternal = (
241
155
  !config.shouldRollbackOnReorg || isInReorgThreshold ? 0 : chainConfig.maxReorgDepth,
242
156
  chainConfig.blockLag,
243
157
  ),
244
- ~onBlockConfigs?,
158
+ ~onBlockRegistrations,
245
159
  ~firstEventBlock,
246
160
  )
247
161
 
@@ -258,16 +172,6 @@ let makeInternal = (
258
172
  let lowercaseAddresses = config.lowercaseAddresses
259
173
  let sources = switch chainConfig.sourceConfig {
260
174
  | Config.EvmSourceConfig({hypersync, rpcs}) =>
261
- // Build Internal.evmContractConfig from contracts for EvmChain.makeSources
262
- let evmContracts: array<Internal.evmContractConfig> = chainConfig.contracts->Array.map((
263
- contract
264
- ): Internal.evmContractConfig => {
265
- name: contract.name,
266
- abi: contract.abi,
267
- events: contract.events->(
268
- Utils.magic: array<Internal.eventConfig> => array<Internal.evmEventConfig>
269
- ),
270
- })
271
175
  let evmRpcs: array<EvmChain.rpc> = rpcs->Array.map((rpc): EvmChain.rpc => {
272
176
  let syncConfig = rpc.syncConfig
273
177
  let ws = rpc.ws
@@ -282,7 +186,9 @@ let makeInternal = (
282
186
  })
283
187
  EvmChain.makeSources(
284
188
  ~chain,
285
- ~contracts=evmContracts,
189
+ ~onEventRegistrations=onEventRegistrations->(
190
+ Utils.magic: array<Internal.onEventRegistration> => array<Internal.evmOnEventRegistration>
191
+ ),
286
192
  ~hyperSync=hypersync,
287
193
  ~rpcs=evmRpcs,
288
194
  ~lowercaseAddresses,
@@ -300,17 +206,13 @@ let makeInternal = (
300
206
  | (Some(hypersyncUrl), _) =>
301
207
  // HyperSync drives instruction sync. A configured RPC is ignored for now
302
208
  // (RPC fallback isn't wired up yet).
303
- let svmEventConfigs =
304
- chainConfig.contracts
305
- ->Array.flatMap(contract => contract.events)
306
- ->(Utils.magic: array<Internal.eventConfig> => array<Internal.svmInstructionEventConfig>)
307
209
  let apiToken = Env.envioApiToken
308
210
  [
309
211
  SvmHyperSyncSource.make({
310
212
  chain,
311
213
  endpointUrl: hypersyncUrl,
312
214
  apiToken,
313
- eventConfigs: svmEventConfigs,
215
+ onEventRegistrations,
314
216
  clientTimeoutMillis: Env.hyperSyncClientTimeoutMillis,
315
217
  }),
316
218
  ]
@@ -319,6 +221,14 @@ let makeInternal = (
319
221
  | Config.CustomSources(sources) => sources
320
222
  }
321
223
 
224
+ // Seed chain density from whatever progress this chain already has (from a
225
+ // resumed DB state, or 0 on a fresh chain) — refined per-batch afterwards.
226
+ let chainDensity = switch fetchState.firstEventBlock {
227
+ | Some(firstEventBlock) if progressBlockNumber > firstEventBlock && numEventsProcessed > 0. =>
228
+ Some(numEventsProcessed /. (progressBlockNumber - firstEventBlock)->Int.toFloat)
229
+ | _ => None
230
+ }
231
+
322
232
  make(
323
233
  ~chainConfig,
324
234
  ~fetchState,
@@ -341,17 +251,27 @@ let makeInternal = (
341
251
  ~ecosystem=config.ecosystem.name,
342
252
  ~shouldChecksum=!lowercaseAddresses,
343
253
  ),
254
+ ~chainDensity,
255
+ ~blockStore=BlockStore.make(
256
+ ~ecosystem=config.ecosystem.name,
257
+ ~shouldChecksum=!lowercaseAddresses,
258
+ ),
344
259
  ~logger,
345
260
  )
346
261
  }
347
262
 
348
- let makeFromConfig = (chainConfig: Config.chain, ~config, ~registrations, ~knownHeight) => {
263
+ let makeFromConfig = (
264
+ chainConfig: Config.chain,
265
+ ~config,
266
+ ~registrationsByChainId,
267
+ ~knownHeight,
268
+ ) => {
349
269
  let logger = Logging.createChild(~params={"chainId": chainConfig.id})
350
270
 
351
271
  makeInternal(
352
272
  ~chainConfig,
353
273
  ~config,
354
- ~registrations,
274
+ ~registrationsByChainId,
355
275
  ~startBlock=chainConfig.startBlock,
356
276
  ~endBlock=chainConfig.endBlock,
357
277
  ~reorgCheckpoints=[],
@@ -377,7 +297,7 @@ let makeFromDbState = (
377
297
  ~isInReorgThreshold,
378
298
  ~isRealtime,
379
299
  ~config,
380
- ~registrations,
300
+ ~registrationsByChainId,
381
301
  ~reducedPollingInterval=?,
382
302
  ) => {
383
303
  let chainId = chainConfig.id
@@ -397,7 +317,7 @@ let makeFromDbState = (
397
317
  ~startBlock=resumedChainState.startBlock,
398
318
  ~endBlock=resumedChainState.endBlock,
399
319
  ~config,
400
- ~registrations,
320
+ ~registrationsByChainId,
401
321
  ~reorgCheckpoints,
402
322
  ~maxReorgDepth=resumedChainState.maxReorgDepth,
403
323
  ~firstEventBlock=resumedChainState.firstEventBlockNumber,
@@ -435,8 +355,7 @@ let contractAddresses = (cs: t, ~contractName) =>
435
355
  let bufferSize = (cs: t) => cs.fetchState->FetchState.bufferSize
436
356
  let bufferReadyCount = (cs: t) => cs.fetchState->FetchState.bufferReadyCount
437
357
  let getProgressPercentage = (cs: t) => cs.fetchState->FetchState.getProgressPercentage
438
- let getProgressPercentageAt = (cs: t, ~blockNumber) =>
439
- cs.fetchState->FetchState.getProgressPercentageAt(~blockNumber)
358
+ let chainDensity = (cs: t) => cs.chainDensity
440
359
  let hasReadyItem = (cs: t) =>
441
360
  cs.fetchState->FetchState.isActivelyIndexing && cs.fetchState->FetchState.hasReadyItem
442
361
  let isReadyToEnterReorgThreshold = (cs: t) => cs.fetchState->FetchState.isReadyToEnterReorgThreshold
@@ -447,7 +366,8 @@ let isReadyToEnterReorgThreshold = (cs: t) => cs.fetchState->FetchState.isReadyT
447
366
  let startFetchingQueries = (cs: t, ~queries: array<FetchState.query>) => {
448
367
  cs.fetchState->FetchState.startFetchingQueries(~queries)
449
368
  cs.pendingBudget =
450
- cs.pendingBudget +. queries->Array.reduce(0., (acc, query) => acc +. query.estResponseSize)
369
+ cs.pendingBudget +.
370
+ queries->Array.reduce(0., (acc, query) => acc +. query.itemsTarget->Int.toFloat)
451
371
  }
452
372
 
453
373
  // Drop every in-flight query and release their reservations together, keeping
@@ -457,10 +377,109 @@ let resetPendingQueries = (cs: t) => {
457
377
  cs.pendingBudget = 0.
458
378
  }
459
379
 
460
- // Propose the chain's candidate queries against their natural ceiling
461
- // (head/endBlock/mergeBlock). CrossChainState admits them against the shared
462
- // buffer budget afterwards.
463
- let getNextQuery = (cs: t) => cs.fetchState->FetchState.getNextQuery
380
+ let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
381
+
382
+ // Block span over which a batch fully replaces the chain density estimate;
383
+ // smaller batches blend in proportionally, so a few sparse/dense blocks only
384
+ // nudge it.
385
+ let densityBlendWindow = 100.
386
+
387
+ // The last block this chain can fetch right now: the head, or endBlock when
388
+ // it's below the head.
389
+ let fetchCeiling = (cs: t) => {
390
+ let fetchState = cs.fetchState
391
+ switch fetchState.endBlock {
392
+ | Some(endBlock) => Pervasives.min(endBlock, fetchState.knownHeight)
393
+ | None => fetchState.knownHeight
394
+ }
395
+ }
396
+
397
+ // This chain's share of the indexer-wide buffer budget turned into a soft
398
+ // target block: via chain density, or the fetch ceiling when density isn't
399
+ // known yet.
400
+ let targetBlock = (cs: t, ~chainTargetItems: float) => {
401
+ let fetchState = cs.fetchState
402
+ let fetchCeiling = cs->fetchCeiling
403
+ let bufferBlockNumber = fetchState->FetchState.bufferBlockNumber
404
+ switch cs.chainDensity {
405
+ | Some(density) if density > 0. =>
406
+ Pervasives.min(
407
+ fetchCeiling,
408
+ bufferBlockNumber + Math.ceil(chainTargetItems /. density)->Float.toInt,
409
+ )
410
+ | _ => fetchCeiling
411
+ }
412
+ }
413
+
414
+ // Block range that cross-chain progress alignment maps fractions over: from
415
+ // the first block that can hold this chain's events to the last block it will
416
+ // fetch.
417
+ let progressRange = (cs: t) => {
418
+ let fetchState = cs.fetchState
419
+ let lower = fetchState.firstEventBlock->Option.getOr(fetchState.startBlock)
420
+ let upper = switch fetchState.endBlock {
421
+ | Some(endBlock) => endBlock
422
+ | None => fetchState.knownHeight
423
+ }
424
+ (lower, upper)
425
+ }
426
+
427
+ // A degenerate range (chain already at or past its last block) maps to 1 so it
428
+ // never constrains the other chains. Clamped at 0 for the initial -1 fetch
429
+ // frontier — the only possible blockNumber below the range's lower bound.
430
+ let progressAtBlock = (cs: t, ~blockNumber) => {
431
+ let (lower, upper) = cs->progressRange
432
+ upper <= lower
433
+ ? 1.
434
+ : Pervasives.max(
435
+ 0.,
436
+ Pervasives.min(1., (blockNumber - lower)->Int.toFloat /. (upper - lower)->Int.toFloat),
437
+ )
438
+ }
439
+
440
+ let blockAtProgress = (cs: t, ~progress) => {
441
+ let (lower, upper) = cs->progressRange
442
+ lower + Math.ceil(progress *. (upper - lower)->Int.toFloat)->Float.toInt
443
+ }
444
+
445
+ // Propose queries sized against this chain's target block. Called by
446
+ // CrossChainState's waterfall, furthest-behind chain first, with
447
+ // chainTargetItems set to whatever budget remains at that point and
448
+ // maxTargetBlock set to the most-behind chain's progress mapped onto this
449
+ // chain, so a chain with budget can't run further ahead than the chain the
450
+ // whole pool is prioritizing.
451
+ let getNextQuery = (cs: t, ~chainTargetItems: float, ~maxTargetBlock=?) => {
452
+ let chainTargetBlock = cs->targetBlock(~chainTargetItems)
453
+ let chainTargetBlock = switch maxTargetBlock {
454
+ | Some(maxTargetBlock) => Pervasives.min(chainTargetBlock, maxTargetBlock)
455
+ | None => chainTargetBlock
456
+ }
457
+ // When the target block is clamped (head/endBlock/cross-chain alignment) a
458
+ // known-density chain can't use the whole handed budget — cap the fresh part
459
+ // at what the clamped range actually costs (in-flight reservations stay on
460
+ // top: they're already accounted and shouldn't crowd out new partitions), so
461
+ // the waterfall's leftover flows to the next chain in the same tick instead
462
+ // of being held by an oversized probe.
463
+ let chainTargetItems = switch cs.chainDensity {
464
+ | Some(density) if density > 0. =>
465
+ let rangeCost =
466
+ density *. (chainTargetBlock - cs.fetchState->FetchState.bufferBlockNumber)->Int.toFloat
467
+ // 3x headroom only for a chain already caught up once and polling the
468
+ // head: there a single query covers the whole remaining range, and a
469
+ // slightly denser-than-expected range would otherwise truncate at the
470
+ // server cap and force an immediate catch-up query for the last blocks.
471
+ // During backfill the range never fits one query anyway, so headroom
472
+ // would just hold budget away from other chains.
473
+ let rangeCost =
474
+ chainTargetBlock >= cs->fetchCeiling && cs->isReady ? rangeCost *. 3. : rangeCost
475
+ Pervasives.min(chainTargetItems, Math.ceil(rangeCost) +. cs.pendingBudget)
476
+ | _ =>
477
+ // No density signal yet: bound the probe so one unknown chain doesn't
478
+ // hold the whole pool while it takes its first measurements.
479
+ Pervasives.min(chainTargetItems, 5_000. +. cs.pendingBudget)
480
+ }
481
+ cs.fetchState->FetchState.getNextQuery(~chainTargetBlock, ~chainTargetItems)
482
+ }
464
483
 
465
484
  // Run a fetch tick for this chain against its sources, feeding the owned fetch
466
485
  // frontier to the source manager.
@@ -498,8 +517,6 @@ let getHighestBlockBelowThreshold = (cs: t): int => {
498
517
 
499
518
  let isActivelyIndexing = (cs: t) => cs.fetchState->FetchState.isActivelyIndexing
500
519
 
501
- let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
502
-
503
520
  // True once the fetch frontier has reached the head/endBlock for this chain.
504
521
  let isFetchingAtHead = (cs: t) => cs.fetchState->FetchState.isFetchingAtHead
505
522
 
@@ -511,21 +528,189 @@ let isAtHeadWithoutEndBlock = (cs: t) =>
511
528
  // --- State transitions. The chain state is mutated only through these; each
512
529
  // owns a cohesive update so callers don't juggle individual fields. ---
513
530
 
514
- // Apply a fetch response: register any new dynamic contracts, append the items
515
- // to the buffer and advance the known head.
516
- // Materialise the chain store's selected transaction fields onto a batch's
517
- // items at batch prep (the persistent-store path).
518
- let materializeBatchItems = (cs: t, ~items: array<Internal.item>) =>
519
- cs.transactionStore->TransactionStore.materializeItems(~items)
520
-
521
- // Materialise a fetch-response page's transactions onto its items before
522
- // contract-register handlers read them. `None` pages (RPC/Fuel/Simulate keep the
523
- // transaction inline) are a no-op.
524
- let materializePageItems = (~items: array<Internal.item>, ~page: option<TransactionStore.t>) =>
525
- switch page {
526
- | Some(store) => store->TransactionStore.materializeItems(~items)
527
- | None => Promise.resolve()
531
+ // Per-store grouping data a single pass over `items` produces (see
532
+ // `groupBatchItems`), fed into `TransactionStore`/`BlockStore`'s `materialize`.
533
+ type transactionGroups = {
534
+ txBlockNumbers: array<int>,
535
+ transactionIndices: array<int>,
536
+ transactionMasks: array<float>,
537
+ payloadGroups: array<array<Internal.eventPayload>>,
538
+ anyTransactionFieldSelected: bool,
539
+ }
540
+ type blockGroups = {
541
+ blockBlockNumbers: array<int>,
542
+ blockMasks: array<float>,
543
+ blockItemGroups: array<array<Internal.eventItem>>,
544
+ }
545
+
546
+ // Walk `items` once to build both the transaction-store and block-store
547
+ // grouping data, instead of `TransactionStore`/`BlockStore` each re-walking the
548
+ // same batch independently. Items already arrive in (blockNumber, logIndex)
549
+ // order, so a (blockNumber, transactionIndex) run and a blockNumber-only run
550
+ // each stay adjacent; every item is checked against both, in the same pass.
551
+ // `includeBlocks` skips the block side entirely for Fuel, which carries the
552
+ // block inline and has no store.
553
+ let groupBatchItems = (items: array<Internal.item>, ~includeBlocks: bool): (
554
+ transactionGroups,
555
+ blockGroups,
556
+ ) => {
557
+ let txBlockNumbers = []
558
+ let transactionIndices = []
559
+ let transactionMasks = []
560
+ let payloadGroups = []
561
+ let anyTransactionFieldSelected = ref(false)
562
+
563
+ let blockBlockNumbers = []
564
+ let blockMasks = []
565
+ let blockItemGroups = []
566
+
567
+ items->Array.forEach(item =>
568
+ switch item {
569
+ | Internal.Event(_) =>
570
+ let eventItem = item->Internal.castUnsafeEventItem
571
+ let {blockNumber} = eventItem
572
+
573
+ switch eventItem.payload->Internal.getPayloadTransaction->Nullable.toOption {
574
+ | Some(_) => () // RPC/simulate/Fuel carry the transaction inline.
575
+ | None =>
576
+ let {transactionIndex} = eventItem
577
+ let mask = eventItem.onEventRegistration.eventConfig.transactionFieldMask
578
+ if mask != 0. {
579
+ anyTransactionFieldSelected := true
580
+ }
581
+ let last = payloadGroups->Array.length - 1
582
+ if (
583
+ last >= 0 &&
584
+ txBlockNumbers->Array.getUnsafe(last) == blockNumber &&
585
+ transactionIndices->Array.getUnsafe(last) == transactionIndex
586
+ ) {
587
+ payloadGroups->Array.getUnsafe(last)->Array.push(eventItem.payload)
588
+ transactionMasks->Array.setUnsafe(
589
+ last,
590
+ FieldMask.orMask(transactionMasks->Array.getUnsafe(last), mask),
591
+ )
592
+ } else {
593
+ txBlockNumbers->Array.push(blockNumber)
594
+ transactionIndices->Array.push(transactionIndex)
595
+ transactionMasks->Array.push(mask)
596
+ payloadGroups->Array.push([eventItem.payload])
597
+ }
598
+ }
599
+
600
+ if includeBlocks {
601
+ switch eventItem.payload->Internal.getPayloadBlock->Nullable.toOption {
602
+ | Some(_) => () // RPC/simulate/Fuel carry the block inline.
603
+ | None =>
604
+ let mask = eventItem.onEventRegistration.eventConfig.blockFieldMask
605
+ let last = blockItemGroups->Array.length - 1
606
+ if last >= 0 && blockBlockNumbers->Array.getUnsafe(last) == blockNumber {
607
+ blockItemGroups->Array.getUnsafe(last)->Array.push(eventItem)
608
+ blockMasks->Array.setUnsafe(
609
+ last,
610
+ FieldMask.orMask(blockMasks->Array.getUnsafe(last), mask),
611
+ )
612
+ } else {
613
+ blockBlockNumbers->Array.push(blockNumber)
614
+ blockMasks->Array.push(mask)
615
+ blockItemGroups->Array.push([eventItem])
616
+ }
617
+ }
618
+ }
619
+ | Internal.Block(_) => ()
620
+ }
621
+ )
622
+
623
+ (
624
+ {
625
+ txBlockNumbers,
626
+ transactionIndices,
627
+ transactionMasks,
628
+ payloadGroups,
629
+ anyTransactionFieldSelected: anyTransactionFieldSelected.contents,
630
+ },
631
+ {blockBlockNumbers, blockMasks, blockItemGroups},
632
+ )
633
+ }
634
+
635
+ // Materialise a `TransactionStore` against precomputed groups (see
636
+ // `groupBatchItems`). Store-backed items always get a transaction object — the
637
+ // selected fields, or `{}` when nothing was selected — so `event.transaction`
638
+ // is never `undefined` (matching the inline sources).
639
+ let applyTransactionGroups = async (store: TransactionStore.t, g: transactionGroups) => {
640
+ if g.payloadGroups->Utils.Array.notEmpty {
641
+ if g.anyTransactionFieldSelected {
642
+ let txs = await store->TransactionStore.materialize(
643
+ ~blockNumbers=g.txBlockNumbers,
644
+ ~transactionIndices=g.transactionIndices,
645
+ ~masks=g.transactionMasks,
646
+ )
647
+ g.payloadGroups->Array.forEachWithIndex((payloads, i) => {
648
+ let tx = txs->Array.getUnsafe(i)
649
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(tx))
650
+ })
651
+ } else {
652
+ g.payloadGroups->Array.forEach(payloads =>
653
+ payloads->Array.forEach(payload => payload->Internal.setPayloadTransaction(%raw(`{}`)))
654
+ )
655
+ }
528
656
  }
657
+ }
658
+
659
+ // Materialise a `BlockStore` against precomputed groups (see `groupBatchItems`).
660
+ let applyBlockGroups = async (store: BlockStore.t, g: blockGroups) => {
661
+ if g.blockItemGroups->Utils.Array.notEmpty {
662
+ let blocks = await store->BlockStore.materialize(
663
+ ~blockNumbers=g.blockBlockNumbers,
664
+ ~masks=g.blockMasks,
665
+ )
666
+ g.blockItemGroups->Array.forEachWithIndex((group, i) => {
667
+ let block = blocks->Array.getUnsafe(i)
668
+ group->Array.forEach(ei => ei.payload->Internal.setPayloadBlock(block))
669
+ })
670
+ }
671
+ }
672
+
673
+ let includeBlocksForEcosystem = (ecosystem: Ecosystem.name) =>
674
+ switch ecosystem {
675
+ | Evm | Svm => true
676
+ | Fuel => false
677
+ }
678
+
679
+ // Materialise the chain stores' selected transaction and block fields onto a
680
+ // batch's items at batch prep (the persistent-store path). A single pass over
681
+ // `items` (`groupBatchItems`) builds both stores' selection masks before the
682
+ // two independent materialize calls run concurrently.
683
+ let materializeBatchItems = async (cs: t, ~items: array<Internal.item>, ~ecosystem) => {
684
+ let (txGroups, blockGroups) =
685
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
686
+ let _ = await Promise.all2((
687
+ cs.transactionStore->applyTransactionGroups(txGroups),
688
+ cs.blockStore->applyBlockGroups(blockGroups),
689
+ ))
690
+ }
691
+
692
+ // Materialise a fetch-response page's transactions and blocks onto its items
693
+ // before contract-register handlers read them. `None` pages (RPC/Fuel/Simulate
694
+ // keep them inline) are a no-op.
695
+ let materializePageItems = async (
696
+ ~items: array<Internal.item>,
697
+ ~transactionStore: option<TransactionStore.t>,
698
+ ~blockStore: option<BlockStore.t>,
699
+ ~ecosystem,
700
+ ) => {
701
+ let (txGroups, blockGroups) =
702
+ items->groupBatchItems(~includeBlocks=ecosystem->includeBlocksForEcosystem)
703
+ let _ = await Promise.all2((
704
+ switch transactionStore {
705
+ | Some(store) => store->applyTransactionGroups(txGroups)
706
+ | None => Promise.resolve()
707
+ },
708
+ switch blockStore {
709
+ | Some(store) => store->applyBlockGroups(blockGroups)
710
+ | None => Promise.resolve()
711
+ },
712
+ ))
713
+ }
529
714
 
530
715
  let handleQueryResult = (
531
716
  cs: t,
@@ -534,14 +719,19 @@ let handleQueryResult = (
534
719
  ~newItemsWithDcs,
535
720
  ~latestFetchedBlock,
536
721
  ~knownHeight,
537
- ~transactionStore as page: option<TransactionStore.t>,
722
+ ~transactionStore as txPage: option<TransactionStore.t>,
723
+ ~blockStore as blockPage: option<BlockStore.t>,
538
724
  ) => {
539
- // Merge this response's page into the chain store in lockstep with appending
540
- // its items to the buffer. Inline-transaction sources contribute no page.
541
- switch page {
725
+ // Merge this response's pages into the chain stores in lockstep with appending
726
+ // its items to the buffer. Inline sources contribute no page.
727
+ switch txPage {
542
728
  | Some(page) => cs.transactionStore->TransactionStore.merge(page)
543
729
  | None => ()
544
730
  }
731
+ switch blockPage {
732
+ | Some(page) => cs.blockStore->BlockStore.merge(page)
733
+ | None => ()
734
+ }
545
735
 
546
736
  let fs = switch newItemsWithDcs {
547
737
  | [] => cs.fetchState
@@ -563,7 +753,7 @@ let handleQueryResult = (
563
753
  ->FetchState.updateKnownHeight(~knownHeight)
564
754
 
565
755
  // The query is no longer in flight, so release its reservation.
566
- cs.pendingBudget = Pervasives.max(0., cs.pendingBudget -. query.estResponseSize)
756
+ cs.pendingBudget = Pervasives.max(0., cs.pendingBudget -. query.itemsTarget->Int.toFloat)
567
757
  }
568
758
 
569
759
  // Run reorg detection against a fetch response and commit the updated guard.
@@ -670,8 +860,9 @@ let advanceAfterBatch = (cs: t, ~batch: Batch.t, ~enteringReorgThreshold) =>
670
860
  // Commit a processed batch's progress for this chain (progress block, events
671
861
  // processed, head/safe-checkpoint tracking, first event block). Emits the
672
862
  // per-chain progress metrics. Readiness is decided by CrossChainState once every
673
- // chain is caught up (see markReady).
674
- let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
863
+ // chain is caught up (see markReady). `blockTimestampName` is the ecosystem's
864
+ // block-timestamp field, read off the payload block for the latency metric.
865
+ let applyBatchProgress = (cs: t, ~batch: Batch.t, ~blockTimestampName: string) => {
675
866
  let chainId = cs.chainConfig.id
676
867
 
677
868
  switch batch.progressedChainsById->Utils.Dict.dangerouslyGetByIntNonOption(chainId) {
@@ -689,15 +880,27 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
689
880
  )
690
881
  }
691
882
 
692
- // Calculate and set latency metrics
693
- switch batch->Batch.findLastEventItem(~chainId) {
694
- | Some(eventItem) => {
695
- let blockTimestampMs = eventItem.timestamp * 1000
696
- Prometheus.ProgressLatency.set(
697
- ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
698
- ~chainId,
699
- )
700
- }
883
+ // Calculate and set latency metrics. The payload block is materialised or
884
+ // inline by processing time; its timestamp may still be absent (e.g. an
885
+ // SVM slot with no block row) the metric is skipped then.
886
+ switch batch
887
+ ->Batch.findLastEventItem(~chainId)
888
+ ->Option.flatMap(eventItem =>
889
+ eventItem.payload
890
+ ->Internal.getPayloadBlock
891
+ ->Nullable.toOption
892
+ )
893
+ ->Option.flatMap(block =>
894
+ block
895
+ ->(Utils.magic: Internal.eventBlock => dict<unknown>)
896
+ ->Utils.Dict.dangerouslyGetNonOption(blockTimestampName)
897
+ ) {
898
+ | Some(blockTimestamp) =>
899
+ let blockTimestampMs = blockTimestamp->(Utils.magic: unknown => int) * 1000
900
+ Prometheus.ProgressLatency.set(
901
+ ~latencyMs=Date.now()->Float.toInt - blockTimestampMs,
902
+ ~chainId,
903
+ )
701
904
  | None => ()
702
905
  }
703
906
 
@@ -712,10 +915,35 @@ let applyBatchProgress = (cs: t, ~batch: Batch.t) => {
712
915
  }
713
916
  }
714
917
 
918
+ // Chain-wide density update: seed with the batch's own events/block on
919
+ // the first update, then blend weighted by the batch's block span — a
920
+ // few sparse/dense blocks barely nudge the estimate, while a
921
+ // window-sized batch replaces it.
922
+ let deltaBlocks = chainAfterBatch.progressBlockNumber - cs.committedProgressBlockNumber
923
+ if deltaBlocks > 0 {
924
+ let deltaEvents = chainAfterBatch.totalEventsProcessed -. cs.numEventsProcessed
925
+ // Don't seed a density before the first event is seen — a progress-only
926
+ // batch would otherwise set it to 0, matching the resume-seed guard.
927
+ switch (cs.chainDensity, deltaEvents > 0.) {
928
+ | (None, false) => ()
929
+ | _ =>
930
+ let batchDensity = deltaEvents /. deltaBlocks->Int.toFloat
931
+ cs.chainDensity = Some(
932
+ switch cs.chainDensity {
933
+ | None => batchDensity
934
+ | Some(oldDensity) =>
935
+ let alpha = Pervasives.min(1., deltaBlocks->Int.toFloat /. densityBlendWindow)
936
+ oldDensity *. (1. -. alpha) +. batchDensity *. alpha
937
+ },
938
+ )
939
+ }
940
+ }
941
+
715
942
  cs.committedProgressBlockNumber = chainAfterBatch.progressBlockNumber
716
943
  cs.numEventsProcessed = chainAfterBatch.totalEventsProcessed
717
- // Processed blocks' transactions are no longer needed.
944
+ // Processed blocks' transactions and blocks are no longer needed.
718
945
  cs.transactionStore->TransactionStore.prune(chainAfterBatch.progressBlockNumber)
946
+ cs.blockStore->BlockStore.prune(chainAfterBatch.progressBlockNumber)
719
947
  cs.isProgressAtHead = cs.isProgressAtHead || chainAfterBatch.isProgressAtHeadWhenBatchCreated
720
948
  switch cs.safeCheckpointTracking {
721
949
  | Some(safeCheckpointTracking) =>
@@ -794,6 +1022,7 @@ let rollback = (
794
1022
  ~targetBlockNumber=newProgressBlockNumber,
795
1023
  )
796
1024
  cs.transactionStore->TransactionStore.rollback(newProgressBlockNumber)
1025
+ cs.blockStore->BlockStore.rollback(newProgressBlockNumber)
797
1026
  cs.committedProgressBlockNumber = newProgressBlockNumber
798
1027
  cs.numEventsProcessed = newTotalEventsProcessed
799
1028
  | None =>
@@ -808,6 +1037,7 @@ let rollback = (
808
1037
  ~targetBlockNumber=rollbackTargetBlockNumber,
809
1038
  )
810
1039
  cs.transactionStore->TransactionStore.rollback(rollbackTargetBlockNumber)
1040
+ cs.blockStore->BlockStore.rollback(rollbackTargetBlockNumber)
811
1041
  cs.committedProgressBlockNumber = Pervasives.min(
812
1042
  cs.committedProgressBlockNumber,
813
1043
  rollbackTargetBlockNumber,