envio 3.3.0-alpha.8 → 3.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/index.d.ts +19 -0
  2. package/licenses/CLA.md +35 -0
  3. package/licenses/EULA.md +67 -0
  4. package/licenses/LICENSE.md +45 -0
  5. package/licenses/README.md +35 -0
  6. package/package.json +9 -8
  7. package/src/Batch.res.mjs +1 -1
  8. package/src/BatchProcessing.res +0 -7
  9. package/src/BatchProcessing.res.mjs +1 -8
  10. package/src/ChainFetching.res +30 -18
  11. package/src/ChainFetching.res.mjs +23 -13
  12. package/src/ChainState.res +205 -59
  13. package/src/ChainState.res.mjs +130 -27
  14. package/src/ChainState.resi +16 -1
  15. package/src/Config.res +9 -5
  16. package/src/Config.res.mjs +2 -2
  17. package/src/Core.res +20 -0
  18. package/src/Core.res.mjs +12 -0
  19. package/src/CrossChainState.res +147 -39
  20. package/src/CrossChainState.res.mjs +42 -10
  21. package/src/EffectState.res +100 -0
  22. package/src/EffectState.res.mjs +74 -0
  23. package/src/EffectState.resi +32 -0
  24. package/src/Envio.res +25 -7
  25. package/src/Envio.res.mjs +15 -19
  26. package/src/EnvioGlobal.res +53 -0
  27. package/src/EnvioGlobal.res.mjs +31 -0
  28. package/src/EventConfigBuilder.res +30 -56
  29. package/src/EventConfigBuilder.res.mjs +24 -30
  30. package/src/EventProcessing.res +8 -5
  31. package/src/EventProcessing.res.mjs +2 -1
  32. package/src/FetchState.res +842 -474
  33. package/src/FetchState.res.mjs +545 -356
  34. package/src/HandlerLoader.res +1 -1
  35. package/src/HandlerLoader.res.mjs +1 -1
  36. package/src/HandlerRegister.res +478 -305
  37. package/src/HandlerRegister.res.mjs +277 -209
  38. package/src/HandlerRegister.resi +11 -6
  39. package/src/InMemoryStore.res +14 -26
  40. package/src/InMemoryStore.res.mjs +6 -19
  41. package/src/IndexerState.res +15 -39
  42. package/src/IndexerState.res.mjs +18 -29
  43. package/src/IndexerState.resi +2 -10
  44. package/src/Internal.res +121 -18
  45. package/src/Internal.res.mjs +96 -2
  46. package/src/LoadLayer.res +44 -24
  47. package/src/LoadLayer.res.mjs +43 -20
  48. package/src/LoadLayer.resi +1 -0
  49. package/src/LogSelection.res +92 -217
  50. package/src/LogSelection.res.mjs +95 -184
  51. package/src/Main.res +29 -143
  52. package/src/Main.res.mjs +25 -88
  53. package/src/Metrics.res +1 -1
  54. package/src/Metrics.res.mjs +1 -1
  55. package/src/Persistence.res +12 -3
  56. package/src/PgStorage.res +155 -82
  57. package/src/PgStorage.res.mjs +130 -77
  58. package/src/Prometheus.res +20 -10
  59. package/src/Prometheus.res.mjs +22 -10
  60. package/src/PruneStaleHistory.res +146 -35
  61. package/src/PruneStaleHistory.res.mjs +114 -20
  62. package/src/RawEvent.res +2 -2
  63. package/src/Rollback.res +32 -13
  64. package/src/Rollback.res.mjs +24 -11
  65. package/src/RollbackCommit.res +4 -1
  66. package/src/RollbackCommit.res.mjs +3 -2
  67. package/src/SimulateDeadInputTracker.res +1 -1
  68. package/src/SimulateItems.res +39 -7
  69. package/src/SimulateItems.res.mjs +28 -9
  70. package/src/TestIndexer.res +2 -2
  71. package/src/TestIndexer.res.mjs +2 -2
  72. package/src/TopicFilter.res +1 -25
  73. package/src/TopicFilter.res.mjs +0 -74
  74. package/src/UserContext.res +62 -23
  75. package/src/UserContext.res.mjs +26 -6
  76. package/src/Writing.res +60 -21
  77. package/src/Writing.res.mjs +27 -9
  78. package/src/bindings/NodeJs.res +5 -0
  79. package/src/bindings/Viem.res +0 -5
  80. package/src/sources/EventRouter.res +0 -21
  81. package/src/sources/EventRouter.res.mjs +0 -12
  82. package/src/sources/Evm.res +4 -1
  83. package/src/sources/Evm.res.mjs +1 -1
  84. package/src/sources/EvmChain.res +2 -27
  85. package/src/sources/EvmChain.res.mjs +2 -23
  86. package/src/sources/EvmRpcClient.res +58 -23
  87. package/src/sources/EvmRpcClient.res.mjs +11 -5
  88. package/src/sources/Fuel.res +3 -1
  89. package/src/sources/Fuel.res.mjs +1 -1
  90. package/src/sources/HyperSync.res +9 -38
  91. package/src/sources/HyperSync.res.mjs +16 -28
  92. package/src/sources/HyperSync.resi +2 -2
  93. package/src/sources/HyperSyncClient.res +88 -11
  94. package/src/sources/HyperSyncClient.res.mjs +39 -6
  95. package/src/sources/HyperSyncSource.res +18 -210
  96. package/src/sources/HyperSyncSource.res.mjs +9 -137
  97. package/src/sources/Rpc.res +0 -32
  98. package/src/sources/Rpc.res.mjs +1 -46
  99. package/src/sources/RpcSource.res +129 -533
  100. package/src/sources/RpcSource.res.mjs +161 -379
  101. package/src/sources/SimulateSource.res +37 -19
  102. package/src/sources/SimulateSource.res.mjs +27 -10
  103. package/src/sources/SourceManager.res +4 -14
  104. package/src/sources/SourceManager.res.mjs +3 -9
  105. package/src/sources/SourceManager.resi +0 -2
  106. package/src/sources/SvmHyperSyncSource.res +13 -3
  107. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
@@ -8,6 +8,7 @@ let configAddresses: Config.chain => array<Internal.indexingAddress>
8
8
  let make: (
9
9
  ~chainConfig: Config.chain,
10
10
  ~fetchState: FetchState.t,
11
+ ~onEventRegistrations: array<Internal.onEventRegistration>=?,
11
12
  ~indexingAddresses: IndexingAddresses.t,
12
13
  ~sourceManager: SourceManager.t,
13
14
  ~reorgDetection: ReorgDetection.t,
@@ -60,11 +61,21 @@ let bufferSize: t => int
60
61
  let bufferReadyCount: t => int
61
62
  let getProgressPercentage: t => float
62
63
  let chainDensity: t => option<float>
64
+ let effectiveDensity: t => option<float>
63
65
  let hasReadyItem: t => bool
64
66
  let isReadyToEnterReorgThreshold: t => bool
65
67
 
66
68
  // Fetch control.
67
- let getNextQuery: (t, ~chainTargetItems: float) => FetchState.nextQuery
69
+ let targetBlock: (t, ~chainTargetItems: float) => int
70
+ let blockAtProgress: (t, ~progress: float) => int
71
+ let frontierProgress: t => float
72
+ let getNextQuery: (
73
+ t,
74
+ ~chainTargetItems: float,
75
+ ~chunkItemsMultiplier: float=?,
76
+ ~itemsTargetFloor: int=?,
77
+ ~maxTargetBlock: int=?,
78
+ ) => FetchState.nextQuery
68
79
  let dispatch: (
69
80
  t,
70
81
  ~executeQuery: FetchState.query => promise<unit>,
@@ -103,6 +114,10 @@ let isReady: t => bool
103
114
  let isFetchingAtHead: t => bool
104
115
  let isAtHeadWithoutEndBlock: t => bool
105
116
 
117
+ // Drop over-fetched events an address-param filter rejects, before contract
118
+ // registration runs on them. Uses the chain's current indexing addresses.
119
+ let filterByClientAddress: (t, array<Internal.item>) => array<Internal.item>
120
+
106
121
  // State transitions. The chain state is mutated only through these.
107
122
  let handleQueryResult: (
108
123
  t,
package/src/Config.res CHANGED
@@ -200,14 +200,18 @@ let svmEventDescriptorSchema = S.schema(s =>
200
200
  "transactionFields": s.matches(S.array(Internal.svmTransactionFieldSchema)),
201
201
  "blockFields": s.matches(S.option(S.array(Internal.svmBlockFieldSchema))),
202
202
  "includeLogs": s.matches(S.bool),
203
+ // An array of AND-groups OR-ed together: the CLI normalizes both the flat
204
+ // and `any_of` YAML shapes to `Vec<Vec<SvmAccountFilterJson>>`.
203
205
  "accountFilters": s.matches(
204
206
  S.option(
205
207
  S.array(
206
- S.schema(s =>
207
- {
208
- "position": s.matches(S.int),
209
- "values": s.matches(S.array(S.string)),
210
- }
208
+ S.array(
209
+ S.schema(s =>
210
+ {
211
+ "position": s.matches(S.int),
212
+ "values": s.matches(S.array(S.string)),
213
+ }
214
+ ),
211
215
  ),
212
216
  ),
213
217
  ),
@@ -115,10 +115,10 @@ let svmEventDescriptorSchema = S$RescriptSchema.schema(s => ({
115
115
  transactionFields: s.m(S$RescriptSchema.array(Internal.svmTransactionFieldSchema)),
116
116
  blockFields: s.m(S$RescriptSchema.option(S$RescriptSchema.array(Internal.svmBlockFieldSchema))),
117
117
  includeLogs: s.m(S$RescriptSchema.bool),
118
- accountFilters: s.m(S$RescriptSchema.option(S$RescriptSchema.array(S$RescriptSchema.schema(s => ({
118
+ accountFilters: s.m(S$RescriptSchema.option(S$RescriptSchema.array(S$RescriptSchema.array(S$RescriptSchema.schema(s => ({
119
119
  position: s.m(S$RescriptSchema.int),
120
120
  values: s.m(S$RescriptSchema.array(S$RescriptSchema.string))
121
- }))))),
121
+ })))))),
122
122
  isInner: s.m(S$RescriptSchema.option(S$RescriptSchema.bool)),
123
123
  accounts: s.m(S$RescriptSchema.option(S$RescriptSchema.array(S$RescriptSchema.string))),
124
124
  args: s.m(S$RescriptSchema.option(S$RescriptSchema.json(false)))
package/src/Core.res CHANGED
@@ -10,9 +10,17 @@ type svmHypersyncClientCtor
10
10
  type hyperfuelClientCtor
11
11
  type transactionStoreCtor
12
12
  type blockStoreCtor
13
+ type parseConfigYamlOptions = {
14
+ schema?: string,
15
+ env?: dict<string>,
16
+ files?: dict<string>,
17
+ isRescript?: bool,
18
+ }
13
19
 
14
20
  type addon = {
15
21
  getConfigJson: (~configPath: Null.t<string>, ~directory: Null.t<string>) => string,
22
+ encodeIndexedTopic: (~abiType: string, ~value: unknown) => EvmTypes.Hex.t,
23
+ parseConfigYaml: (string, parseConfigYamlOptions) => string,
16
24
  runCli: (~args: array<string>, ~envioPackageDir: Null.t<string>) => promise<Null.t<string>>,
17
25
  @as("EvmHypersyncClient")
18
26
  evmHypersyncClient: evmHypersyncClientCtor,
@@ -197,6 +205,18 @@ let getConfigJson = (~configPath=?, ~directory=?) => {
197
205
  )
198
206
  }
199
207
 
208
+ // Pure config entry point: no cwd, config file, schema file, .env, or process env lookup.
209
+ // A supplied schema is authoritative; omitted or blank schema text means an empty schema.
210
+ let parseConfigYaml = (~schema=?, ~env=?, ~files=?, ~isRescript=false, yaml) => {
211
+ let addon = getAddon()
212
+ addon.parseConfigYaml(yaml, {
213
+ ?schema,
214
+ ?env,
215
+ ?files,
216
+ isRescript,
217
+ })
218
+ }
219
+
200
220
  let runCli = args => {
201
221
  let addon = getAddon()
202
222
  addon.runCli(~args, ~envioPackageDir=Null.make(envioPackageDir))
package/src/Core.res.mjs CHANGED
@@ -180,6 +180,17 @@ function getConfigJson(configPath, directory) {
180
180
  return addon.getConfigJson(Stdlib_Null.fromOption(configPath), Stdlib_Null.fromOption(directory));
181
181
  }
182
182
 
183
+ function parseConfigYaml(schema, env, files, isRescriptOpt, yaml) {
184
+ let isRescript = isRescriptOpt !== undefined ? isRescriptOpt : false;
185
+ let addon = getAddon();
186
+ return addon.parseConfigYaml(yaml, {
187
+ schema: schema,
188
+ env: env,
189
+ files: files,
190
+ isRescript: isRescript
191
+ });
192
+ }
193
+
183
194
  function runCli(args) {
184
195
  let addon = getAddon();
185
196
  return addon.runCli(args, envioPackageDir);
@@ -196,6 +207,7 @@ export {
196
207
  addonRef,
197
208
  getAddon,
198
209
  getConfigJson,
210
+ parseConfigYaml,
199
211
  runCli,
200
212
  }
201
213
  /* envioPackageDir Not a pure module */
@@ -19,7 +19,7 @@ type t = {
19
19
  let calculateTargetBufferSize = () =>
20
20
  switch Env.targetBufferSize {
21
21
  | Some(size) => size
22
- | None => 50_000
22
+ | None => 100_000
23
23
  }
24
24
 
25
25
  let make = (
@@ -169,13 +169,18 @@ let applyBatchProgress = (crossChainState: t, ~batch: Batch.t, ~blockTimestampNa
169
169
 
170
170
  // --- Fetch control. ---
171
171
 
172
- // Chains ordered furthest-behind first, so the shared buffer pool goes to the
173
- // chains with the most backfill work before the rest.
172
+ // Chains ordered furthest-behind first by fetch-frontier progress, so the
173
+ // shared buffer pool goes to the chains with the most fetchable backfill work
174
+ // before the rest — and the same metric that sets the alignment line also
175
+ // decides who draws budget first, so the anchor is served before any chain it
176
+ // clamps. (Batch ordering keeps its own getProgressPercentage measure.) A chain
177
+ // with no known height reads 100% here and sorts last, which is fine: it can't
178
+ // fetch until its first block lands regardless of when it's visited.
174
179
  let priorityOrder = (crossChainState: t) =>
175
180
  crossChainState.chainStates
176
181
  ->Dict.valuesToArray
177
182
  ->Array.toSorted((a, b) =>
178
- Float.compare(a->ChainState.getProgressPercentage, b->ChainState.getProgressPercentage)
183
+ Float.compare(a->ChainState.frontierProgress, b->ChainState.frontierProgress)
179
184
  )
180
185
 
181
186
  // In-flight estimated items across every chain — the live draw against
@@ -190,6 +195,22 @@ let totalReservedSize = (crossChainState: t) => {
190
195
  total.contents
191
196
  }
192
197
 
198
+ // Action for a chain that was handed budget but emitted no query (its budget
199
+ // went to more-behind chains, or the alignment clamp cut its range to
200
+ // nothing). A chain is genuinely idle — and correctly left undispatched —
201
+ // when it is caught up to its head/endblock, still draining in-flight
202
+ // queries, or holding ready items that batch processing will drain and
203
+ // re-schedule from. Any other chain must keep polling for new blocks instead
204
+ // of going silent: NothingToQuery isn't dispatched, and with the pool
205
+ // unsaturated nothing else guarantees a tick that would revisit it, so its
206
+ // head tracking would freeze.
207
+ let idleOrWaitAction = (cs: ChainState.t) =>
208
+ cs->ChainState.isFetchingAtHead ||
209
+ cs->ChainState.pendingBudget > 0. ||
210
+ cs->ChainState.bufferReadyCount > 0
211
+ ? FetchState.NothingToQuery
212
+ : FetchState.WaitingForNewBlock
213
+
193
214
  // Dispatch a fetch tick across the whole indexer from one shared pool of
194
215
  // ~targetBufferSize ready events, as a waterfall: visit chains furthest-behind
195
216
  // first, hand each the budget remaining at that point (plus its own
@@ -198,56 +219,143 @@ let totalReservedSize = (crossChainState: t) => {
198
219
  // chain-density-derived target block, then subtract what it actually used
199
220
  // before moving to the next chain. So a chain that can only use a little
200
221
  // (density too low, or already caught up) leaves the rest for the others
201
- // automatically unlike a per-query pool, this can starve a near-head chain
202
- // while an earlier one is deep in backfill, which is the intended tradeoff.
222
+ // automatically. Starting a new query requires at least 10% of the target pool
223
+ // to be free. A chain visited after the budget falls below that admission unit
224
+ // doesn't query this round — reservations release as responses land, so the
225
+ // next tick redistributes. Every other chain is additionally capped at the
226
+ // lowest-frontier-progress chain's progress mapped onto its own range, so no
227
+ // chain runs ahead of the chain the pool is prioritizing — including on ticks
228
+ // where that chain is mid-fetch and emits no new query. A chain with no known
229
+ // height can't anchor this line (there's no range to measure against), a chain
230
+ // caught up to its fetchable head reads 100% and so never anchors while another
231
+ // is behind, and once the whole indexer has caught up (isRealtime) the clamp is
232
+ // dropped — chains at head only trail each other by real-time block production.
203
233
  let checkAndFetch = async (
204
234
  crossChainState: t,
205
235
  ~dispatchChain: (~chain: ChainMap.Chain.t, ~action: FetchState.nextQuery) => promise<unit>,
206
236
  ) => {
237
+ let targetBudget = crossChainState.targetBufferSize->Int.toFloat
207
238
  let remaining = ref(
208
239
  Pervasives.max(
209
240
  0.,
210
- crossChainState.targetBufferSize->Int.toFloat -.
241
+ targetBudget -.
211
242
  crossChainState->totalReadyCount->Int.toFloat -.
212
243
  crossChainState->totalReservedSize,
213
244
  ),
214
245
  )
215
246
 
247
+ // New fetch work is admitted in units of 10% of the target pool. Waiting
248
+ // below this floor avoids spending the last few free items on undersized
249
+ // queries; response and batch completion schedule another tick after they
250
+ // release enough budget.
251
+ let minimumAdmissionBudget = targetBudget *. 0.1
252
+
253
+ // A chain with no density signal probes blind, so it only gets a bounded
254
+ // slice of the pool — one unknown chain shouldn't hold the whole budget
255
+ // while it takes its first measurements. Its probe is one admission unit.
256
+ let coldChainBudget = minimumAdmissionBudget
257
+
258
+ // Chunk reservations get headroom over the density estimate so a
259
+ // denser-than-expected range doesn't truncate at the server cap; realtime
260
+ // gets more since a forced catch-up query there costs a head-poll roundtrip.
261
+ let chunkItemsMultiplier = crossChainState.isRealtime ? 3. : 1.5
262
+
263
+ // Server-cap floor for bounded queries: their block range is already the
264
+ // hard bound on the response, so a low density estimate shrinking the cap
265
+ // below this only buys self-truncated responses. Splitting the target pool
266
+ // across a chain's concurrency slots keeps the worst case — every in-flight
267
+ // bounded query returning a full floored response at once — at ~one buffer
268
+ // target.
269
+ let itemsTargetFloor = crossChainState.targetBufferSize / FetchState.maxChainConcurrency
270
+
271
+ let prioritizedChainStates = crossChainState->priorityOrder
272
+
273
+ // Alignment anchor: the first known-height chain in priority order — which,
274
+ // since that order sorts by frontier progress, is the chain furthest behind
275
+ // by the very metric the clamp maps other chains against. Anchoring on the
276
+ // frontier (not on the target of whichever chain happens to query this tick)
277
+ // keeps the line in place while the anchor's queries are still in flight. A
278
+ // chain caught up to its fetchable head reads 100% and sorts past any behind
279
+ // chain, so it never anchors while another chain is behind.
280
+ let alignment = crossChainState.isRealtime
281
+ ? None
282
+ : prioritizedChainStates
283
+ ->Array.find(cs => cs->ChainState.knownHeight != 0)
284
+ ->Option.map(cs => ((cs->ChainState.chainConfig).id, cs->ChainState.frontierProgress))
285
+
216
286
  let actionByChain = Dict.make()
217
- crossChainState
218
- ->priorityOrder
219
- ->Array.forEach(cs => {
287
+ prioritizedChainStates->Array.forEach(cs => {
220
288
  let chainId = (cs->ChainState.chainConfig).id
221
- let chainTargetItems = remaining.contents +. cs->ChainState.pendingBudget
222
- switch cs->ChainState.getNextQuery(~chainTargetItems) {
223
- | (WaitingForNewBlock | NothingToQuery) as action =>
224
- actionByChain->Utils.Dict.setByInt(chainId, action)
225
- | Ready(queries) => {
226
- let consumed =
227
- queries->Array.reduce(0., (acc, query: FetchState.query) => acc +. query.itemsTarget)
228
-
229
- let partitions = Dict.make()
230
- queries->Array.forEach((query: FetchState.query) =>
231
- partitions->Dict.set(
232
- query.partitionId,
233
- {
234
- "fromBlock": query.fromBlock,
235
- "targetBlock": query.toBlock,
236
- "targetEvents": query.itemsTarget->Math.round->Float.toInt,
237
- },
289
+ if cs->ChainState.knownHeight == 0 {
290
+ // No height yet — there's nothing to size a query against, only height
291
+ // tracking to start. Checked before the admission floor so a chain that
292
+ // hasn't found its first block yet keeps polling even while other chains
293
+ // hold the whole pool. (The general can't-fetch-yet rule, including
294
+ // blockLag, lives in FetchState.getNextQuery — this branch only
295
+ // short-circuits the unambiguous no-height case.)
296
+ actionByChain->Utils.Dict.setByInt(chainId, FetchState.WaitingForNewBlock)
297
+ } else if remaining.contents < minimumAdmissionBudget {
298
+ // More than 90% of the target pool is ready or reserved. Don't admit new
299
+ // queries until a full admission unit becomes free. No wake-up poll is
300
+ // needed: a saturated pool means some chain holds ready items or
301
+ // in-flight reservations, so a batch completion or landing response is
302
+ // guaranteed to schedule another tick that revisits this chain.
303
+ actionByChain->Utils.Dict.setByInt(chainId, FetchState.NothingToQuery)
304
+ } else {
305
+ let isCold = cs->ChainState.effectiveDensity === None
306
+ let chainTargetItems =
307
+ (isCold ? Pervasives.min(remaining.contents, coldChainBudget) : remaining.contents) +.
308
+ cs->ChainState.pendingBudget
309
+ let maxTargetBlock = switch alignment {
310
+ // 5% margin past the anchor's line: chains whose progress tracks the
311
+ // anchor closely would otherwise flap in and out of the clamp on every
312
+ // small frontier move, stalling their pipeline every other tick.
313
+ | Some((anchorChainId, progress)) if anchorChainId !== chainId =>
314
+ Some(cs->ChainState.blockAtProgress(~progress=progress +. 0.05))
315
+ | _ => None
316
+ }
317
+ switch cs->ChainState.getNextQuery(
318
+ ~chainTargetItems,
319
+ ~chunkItemsMultiplier,
320
+ ~itemsTargetFloor,
321
+ ~maxTargetBlock?,
322
+ ) {
323
+ | WaitingForNewBlock as action => actionByChain->Utils.Dict.setByInt(chainId, action)
324
+ | NothingToQuery =>
325
+ // A chain below its head can emit no query when its budget went to
326
+ // more-behind chains or the cross-chain alignment clamped its range to
327
+ // nothing — idleOrWaitAction keeps it polling for new blocks.
328
+ actionByChain->Utils.Dict.setByInt(chainId, idleOrWaitAction(cs))
329
+ | Ready(queries) => {
330
+ let consumed =
331
+ queries->Array.reduce(0., (acc, query: FetchState.query) =>
332
+ acc +. query.itemsEst->Int.toFloat
333
+ )
334
+
335
+ let partitions = Dict.make()
336
+ queries->Array.forEach((query: FetchState.query) =>
337
+ partitions->Dict.set(
338
+ query.partitionId,
339
+ {
340
+ "fromBlock": query.fromBlock,
341
+ "targetBlock": query.toBlock,
342
+ "targetEvents": query.itemsTarget,
343
+ },
344
+ )
238
345
  )
239
- )
240
- Logging.trace({
241
- "msg": "Started querying",
242
- "chainId": chainId,
243
- "partitions": partitions,
244
- })
245
-
246
- actionByChain->Utils.Dict.setByInt(chainId, FetchState.Ready(queries))
247
- // Mark the queries in flight and reserve their size against the shared
248
- // budget; released as each response lands in handleQueryResult.
249
- cs->ChainState.startFetchingQueries(~queries)
250
- remaining := Pervasives.max(0., remaining.contents -. consumed)
346
+ Logging.trace({
347
+ "msg": "Started querying",
348
+ "chainId": chainId,
349
+ "partitions": partitions,
350
+ })
351
+
352
+ actionByChain->Utils.Dict.setByInt(chainId, FetchState.Ready(queries))
353
+ // Mark the queries in flight and reserve their size against the
354
+ // shared budget; released as each response lands in
355
+ // handleQueryResult.
356
+ cs->ChainState.startFetchingQueries(~queries)
357
+ remaining := Pervasives.max(0., remaining.contents -. consumed)
358
+ }
251
359
  }
252
360
  }
253
361
  })
@@ -6,8 +6,11 @@ import * as Utils from "./Utils.res.mjs";
6
6
  import * as Logging from "./Logging.res.mjs";
7
7
  import * as ChainMap from "./ChainMap.res.mjs";
8
8
  import * as ChainState from "./ChainState.res.mjs";
9
+ import * as FetchState from "./FetchState.res.mjs";
9
10
  import * as Prometheus from "./Prometheus.res.mjs";
10
11
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
12
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
13
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
11
14
  import * as Primitive_float from "@rescript/runtime/lib/es6/Primitive_float.js";
12
15
  import * as SafeCheckpointTracking from "./SafeCheckpointTracking.res.mjs";
13
16
 
@@ -15,13 +18,13 @@ function calculateTargetBufferSize() {
15
18
  if (Env.targetBufferSize !== undefined) {
16
19
  return Env.targetBufferSize;
17
20
  } else {
18
- return 50000;
21
+ return 100000;
19
22
  }
20
23
  }
21
24
 
22
25
  function make(chainStates, isInReorgThreshold, isRealtime, targetBufferSizeOpt) {
23
26
  let targetBufferSize = targetBufferSizeOpt !== undefined ? targetBufferSizeOpt : (
24
- Env.targetBufferSize !== undefined ? Env.targetBufferSize : 50000
27
+ Env.targetBufferSize !== undefined ? Env.targetBufferSize : 100000
25
28
  );
26
29
  let crossChainState = {
27
30
  chainStates: chainStates,
@@ -121,7 +124,7 @@ function applyBatchProgress(crossChainState, batch, blockTimestampName) {
121
124
  }
122
125
 
123
126
  function priorityOrder(crossChainState) {
124
- return Object.values(crossChainState.chainStates).toSorted((a, b) => Primitive_float.compare(ChainState.getProgressPercentage(a), ChainState.getProgressPercentage(b)));
127
+ return Object.values(crossChainState.chainStates).toSorted((a, b) => Primitive_float.compare(ChainState.frontierProgress(a), ChainState.frontierProgress(b)));
125
128
  }
126
129
 
127
130
  function totalReservedSize(crossChainState) {
@@ -133,31 +136,60 @@ function totalReservedSize(crossChainState) {
133
136
  return total;
134
137
  }
135
138
 
139
+ function idleOrWaitAction(cs) {
140
+ if (ChainState.isFetchingAtHead(cs) || ChainState.pendingBudget(cs) > 0 || ChainState.bufferReadyCount(cs) > 0) {
141
+ return "NothingToQuery";
142
+ } else {
143
+ return "WaitingForNewBlock";
144
+ }
145
+ }
146
+
136
147
  async function checkAndFetch(crossChainState, dispatchChain) {
148
+ let targetBudget = crossChainState.targetBufferSize;
137
149
  let remaining = {
138
- contents: Primitive_float.max(0, crossChainState.targetBufferSize - totalReadyCount(crossChainState) - totalReservedSize(crossChainState))
150
+ contents: Primitive_float.max(0, targetBudget - totalReadyCount(crossChainState) - totalReservedSize(crossChainState))
139
151
  };
152
+ let minimumAdmissionBudget = targetBudget * 0.1;
153
+ let chunkItemsMultiplier = crossChainState.isRealtime ? 3 : 1.5;
154
+ let itemsTargetFloor = Primitive_int.div(crossChainState.targetBufferSize, FetchState.maxChainConcurrency);
155
+ let prioritizedChainStates = priorityOrder(crossChainState);
156
+ let alignment = crossChainState.isRealtime ? undefined : Stdlib_Option.map(prioritizedChainStates.find(cs => ChainState.knownHeight(cs) !== 0), cs => [
157
+ ChainState.chainConfig(cs).id,
158
+ ChainState.frontierProgress(cs)
159
+ ]);
140
160
  let actionByChain = {};
141
- priorityOrder(crossChainState).forEach(cs => {
161
+ prioritizedChainStates.forEach(cs => {
142
162
  let chainId = ChainState.chainConfig(cs).id;
143
- let chainTargetItems = remaining.contents + ChainState.pendingBudget(cs);
144
- let action = ChainState.getNextQuery(cs, chainTargetItems);
163
+ if (ChainState.knownHeight(cs) === 0) {
164
+ actionByChain[chainId] = "WaitingForNewBlock";
165
+ return;
166
+ }
167
+ if (remaining.contents < minimumAdmissionBudget) {
168
+ actionByChain[chainId] = "NothingToQuery";
169
+ return;
170
+ }
171
+ let isCold = ChainState.effectiveDensity(cs) === undefined;
172
+ let chainTargetItems = (
173
+ isCold ? Primitive_float.min(remaining.contents, minimumAdmissionBudget) : remaining.contents
174
+ ) + ChainState.pendingBudget(cs);
175
+ let maxTargetBlock = alignment !== undefined && alignment[0] !== chainId ? ChainState.blockAtProgress(cs, alignment[1] + 0.05) : undefined;
176
+ let action = ChainState.getNextQuery(cs, chainTargetItems, chunkItemsMultiplier, itemsTargetFloor, maxTargetBlock);
145
177
  if (typeof action !== "object") {
146
178
  if (action === "WaitingForNewBlock") {
147
179
  actionByChain[chainId] = action;
148
180
  return;
149
181
  }
150
- actionByChain[chainId] = action;
182
+ actionByChain[chainId] = idleOrWaitAction(cs);
151
183
  return;
152
184
  } else {
153
185
  let queries = action._0;
154
- let consumed = Stdlib_Array.reduce(queries, 0, (acc, query) => acc + query.itemsTarget);
186
+ let consumed = Stdlib_Array.reduce(queries, 0, (acc, query) => acc + query.itemsEst);
155
187
  let partitions = {};
156
188
  queries.forEach(query => {
157
189
  partitions[query.partitionId] = {
158
190
  fromBlock: query.fromBlock,
159
191
  targetBlock: query.toBlock,
160
- targetEvents: Math.round(query.itemsTarget) | 0
192
+ targetEvents: query.itemsTarget
161
193
  };
162
194
  });
163
195
  Logging.trace({
@@ -0,0 +1,100 @@
1
+ // Owns all per-(effect, scope) runtime state and its lifecycle. The two maps
2
+ // have different rollback semantics, so keeping them together behind an explicit
3
+ // `resetForRollback` makes the invariant enforced rather than remembered.
4
+
5
+ // Per-scope rate-limit window and queue. A chain-scoped effect gets one of
6
+ // these per chain, so each chain's throughput is independent.
7
+ type effectRateLimitState = {
8
+ callsPerDuration: int,
9
+ durationMs: int,
10
+ mutable availableCalls: int,
11
+ mutable windowStartTime: float,
12
+ mutable queueCount: int,
13
+ mutable nextWindowPromise: option<promise<unit>>,
14
+ }
15
+
16
+ type effectCacheInMemTable = {
17
+ // Cache keys whose handler output is persisted on the next write. Drained
18
+ // each write; eviction is driven by the per-entry checkpointId instead.
19
+ mutable idsToStore: array<string>,
20
+ mutable invalidationsCount: int,
21
+ // Each entry is stamped with the checkpoint that referenced it (or
22
+ // loadedFromDbCheckpointId for db reads), so committed entries can be
23
+ // dropped once persisted/re-derivable, mirroring entity changes.
24
+ mutable dict: dict<Change.t<Internal.effectOutput>>,
25
+ mutable changesCount: float,
26
+ effect: Internal.effect,
27
+ // The scope this cache is for and its resolved db table (the address). The
28
+ // in-mem table is keyed by `table.tableName`, so a cross-chain and a
29
+ // chain-scoped cache for the same effect stay isolated.
30
+ scope: Internal.chainScope,
31
+ table: Table.table,
32
+ rateLimitState: option<effectRateLimitState>,
33
+ // Per-scope active-call bookkeeping, surfaced through the Effect metrics.
34
+ mutable activeCallsCount: int,
35
+ mutable prevCallStartTimerRef: Performance.timeRef,
36
+ }
37
+
38
+ type t = {
39
+ // Cache tables keyed by cache table name. Dropped on rollback — the cache
40
+ // is re-derivable from the db.
41
+ mutable tables: dict<effectCacheInMemTable>,
42
+ // Rate-limit windows keyed by the same name. Survive rollback: rate limiting
43
+ // reflects real API throughput, not indexing progress, so a reorg must not
44
+ // refill an effect's budget.
45
+ rateLimits: dict<effectRateLimitState>,
46
+ }
47
+
48
+ let make = (): t => {tables: Dict.make(), rateLimits: Dict.make()}
49
+
50
+ let getRateLimitState = (self: t, ~tableName, ~effect: Internal.effect) =>
51
+ switch effect.rateLimit {
52
+ | None => None
53
+ | Some({callsPerDuration, durationMs}) =>
54
+ Some(
55
+ switch self.rateLimits->Utils.Dict.dangerouslyGetNonOption(tableName) {
56
+ | Some(existing) => existing
57
+ | None =>
58
+ let created: effectRateLimitState = {
59
+ callsPerDuration,
60
+ durationMs,
61
+ availableCalls: callsPerDuration,
62
+ windowStartTime: Date.now(),
63
+ queueCount: 0,
64
+ nextWindowPromise: None,
65
+ }
66
+ self.rateLimits->Dict.set(tableName, created)
67
+ created
68
+ },
69
+ )
70
+ }
71
+
72
+ // Get, or lazily create, the in-mem table for an (effect, scope). A recreated
73
+ // table (e.g. after a rollback) reuses the surviving rate-limit window.
74
+ let getTable = (self: t, ~effect: Internal.effect, ~scope: Internal.chainScope) => {
75
+ let tableName = Internal.EffectCache.toTableName(~effectName=effect.name, ~scope)
76
+ switch self.tables->Utils.Dict.dangerouslyGetNonOption(tableName) {
77
+ | Some(inMemTable) => inMemTable
78
+ | None =>
79
+ let inMemTable: effectCacheInMemTable = {
80
+ idsToStore: [],
81
+ dict: Dict.make(),
82
+ changesCount: 0.,
83
+ invalidationsCount: 0,
84
+ effect,
85
+ scope,
86
+ table: Internal.makeCacheTable(~effectName=effect.name, ~scope),
87
+ rateLimitState: self->getRateLimitState(~tableName, ~effect),
88
+ activeCallsCount: 0,
89
+ prevCallStartTimerRef: %raw(`null`),
90
+ }
91
+ self.tables->Dict.set(tableName, inMemTable)
92
+ inMemTable
93
+ }
94
+ }
95
+
96
+ let forEach = (self: t, fn) => self.tables->Utils.Dict.forEach(fn)
97
+
98
+ // Drop the cache tables (re-derivable from the db) but keep the rate-limit
99
+ // windows so a reorg doesn't hand an effect a fresh budget.
100
+ let resetForRollback = (self: t) => self.tables = Dict.make()
@@ -0,0 +1,74 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Utils from "./Utils.res.mjs";
4
+ import * as Internal from "./Internal.res.mjs";
5
+
6
+ function make() {
7
+ return {
8
+ tables: {},
9
+ rateLimits: {}
10
+ };
11
+ }
12
+
13
+ function getRateLimitState(self, tableName, effect) {
14
+ let match = effect.rateLimit;
15
+ if (match === undefined) {
16
+ return;
17
+ }
18
+ let callsPerDuration = match.callsPerDuration;
19
+ let existing = self.rateLimits[tableName];
20
+ let tmp;
21
+ if (existing !== undefined) {
22
+ tmp = existing;
23
+ } else {
24
+ let created = {
25
+ callsPerDuration: callsPerDuration,
26
+ durationMs: match.durationMs,
27
+ availableCalls: callsPerDuration,
28
+ windowStartTime: Date.now(),
29
+ queueCount: 0,
30
+ nextWindowPromise: undefined
31
+ };
32
+ self.rateLimits[tableName] = created;
33
+ tmp = created;
34
+ }
35
+ return tmp;
36
+ }
37
+
38
+ function getTable(self, effect, scope) {
39
+ let tableName = Internal.EffectCache.toTableName(effect.name, scope);
40
+ let inMemTable = self.tables[tableName];
41
+ if (inMemTable !== undefined) {
42
+ return inMemTable;
43
+ }
44
+ let inMemTable$1 = {
45
+ idsToStore: [],
46
+ invalidationsCount: 0,
47
+ dict: {},
48
+ changesCount: 0,
49
+ effect: effect,
50
+ scope: scope,
51
+ table: Internal.makeCacheTable(effect.name, scope),
52
+ rateLimitState: getRateLimitState(self, tableName, effect),
53
+ activeCallsCount: 0,
54
+ prevCallStartTimerRef: null
55
+ };
56
+ self.tables[tableName] = inMemTable$1;
57
+ return inMemTable$1;
58
+ }
59
+
60
+ function forEach(self, fn) {
61
+ Utils.Dict.forEach(self.tables, fn);
62
+ }
63
+
64
+ function resetForRollback(self) {
65
+ self.tables = {};
66
+ }
67
+
68
+ export {
69
+ make,
70
+ getTable,
71
+ forEach,
72
+ resetForRollback,
73
+ }
74
+ /* Utils Not a pure module */