envio 3.3.0-alpha.11 → 3.3.0-alpha.13

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 (40) hide show
  1. package/index.d.ts +19 -0
  2. package/package.json +6 -6
  3. package/src/ChainFetching.res +21 -12
  4. package/src/ChainFetching.res.mjs +20 -9
  5. package/src/ChainState.res +20 -8
  6. package/src/ChainState.res.mjs +13 -12
  7. package/src/ChainState.resi +1 -1
  8. package/src/CrossChainState.res +64 -41
  9. package/src/CrossChainState.res.mjs +18 -13
  10. package/src/EffectState.res +100 -0
  11. package/src/EffectState.res.mjs +74 -0
  12. package/src/EffectState.resi +32 -0
  13. package/src/Envio.res +25 -7
  14. package/src/Envio.res.mjs +15 -19
  15. package/src/FetchState.res +115 -76
  16. package/src/FetchState.res.mjs +114 -104
  17. package/src/InMemoryStore.res +14 -26
  18. package/src/InMemoryStore.res.mjs +6 -19
  19. package/src/IndexerState.res +10 -19
  20. package/src/IndexerState.res.mjs +7 -6
  21. package/src/IndexerState.resi +1 -9
  22. package/src/Internal.res +87 -12
  23. package/src/Internal.res.mjs +96 -2
  24. package/src/LoadLayer.res +44 -24
  25. package/src/LoadLayer.res.mjs +43 -20
  26. package/src/LoadLayer.resi +1 -0
  27. package/src/Persistence.res +12 -3
  28. package/src/PgStorage.res +155 -82
  29. package/src/PgStorage.res.mjs +130 -77
  30. package/src/Prometheus.res +20 -10
  31. package/src/Prometheus.res.mjs +22 -10
  32. package/src/Rollback.res +32 -13
  33. package/src/Rollback.res.mjs +24 -11
  34. package/src/UserContext.res +62 -23
  35. package/src/UserContext.res.mjs +26 -6
  36. package/src/Writing.res +28 -12
  37. package/src/Writing.res.mjs +15 -7
  38. package/src/bindings/NodeJs.res +5 -0
  39. package/src/sources/SvmHyperSyncSource.res +10 -0
  40. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
@@ -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 */
@@ -0,0 +1,32 @@
1
+ type effectRateLimitState = {
2
+ callsPerDuration: int,
3
+ durationMs: int,
4
+ mutable availableCalls: int,
5
+ mutable windowStartTime: float,
6
+ mutable queueCount: int,
7
+ mutable nextWindowPromise: option<promise<unit>>,
8
+ }
9
+
10
+ type effectCacheInMemTable = {
11
+ mutable idsToStore: array<string>,
12
+ mutable invalidationsCount: int,
13
+ mutable dict: dict<Change.t<Internal.effectOutput>>,
14
+ mutable changesCount: float,
15
+ effect: Internal.effect,
16
+ scope: Internal.chainScope,
17
+ table: Table.table,
18
+ rateLimitState: option<effectRateLimitState>,
19
+ mutable activeCallsCount: int,
20
+ mutable prevCallStartTimerRef: Performance.timeRef,
21
+ }
22
+
23
+ // Opaque store of all per-(effect, scope) runtime state.
24
+ type t
25
+
26
+ let make: unit => t
27
+ // Get, or lazily create, the in-mem table for an (effect, scope). Reuses the
28
+ // surviving rate-limit window when the table is recreated after a rollback.
29
+ let getTable: (t, ~effect: Internal.effect, ~scope: Internal.chainScope) => effectCacheInMemTable
30
+ let forEach: (t, effectCacheInMemTable => unit) => unit
31
+ // Drop the cache tables but keep the rate-limit windows across a rollback.
32
+ let resetForRollback: t => unit
package/src/Envio.res CHANGED
@@ -186,11 +186,19 @@ and effectOptions<'input, 'output> = {
186
186
  rateLimit: rateLimit,
187
187
  /** Whether the effect should be cached. */
188
188
  cache?: bool,
189
+ /** Whether the effect's cache is shared across all chains. Defaults to `true`.
190
+ Set to `false` to isolate the cache (and rate limiting) per chain and enable
191
+ `context.chain.id` inside the handler. */
192
+ crossChain?: bool,
189
193
  }
194
+ and effectChain = {id: int}
190
195
  and effectContext = {
191
196
  log: logger,
192
197
  effect: 'input 'output. (effect<'input, 'output>, 'input) => promise<'output>,
193
198
  mutable cache: bool,
199
+ /** The chain the effect was called on. Only available on effects created with
200
+ `crossChain: false`; accessing it on a cross-chain effect throws. */
201
+ chain: effectChain,
194
202
  }
195
203
  and effectArgs<'input> = {
196
204
  input: 'input,
@@ -205,10 +213,23 @@ let durationToMs = (duration: rateLimitDuration) =>
205
213
  | Milliseconds(ms) => ms
206
214
  }
207
215
 
216
+ // The name becomes both a Postgres cache-table suffix and a .envio/cache file
217
+ // path segment. Path separators are excluded from the charset and a leading dot
218
+ // is disallowed, so a name can never be "." / ".." or otherwise traverse out of
219
+ // the cache dir; dots elsewhere are fine and keep existing names like
220
+ // "token.metadata" working, since the (name, scope) <-> table <-> path mapping
221
+ // stays reversible.
222
+ let effectNameRe = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/
223
+
208
224
  let createEffect = (
209
225
  options: effectOptions<'input, 'output>,
210
226
  handler: effectArgs<'input> => promise<'output>,
211
227
  ) => {
228
+ if !(effectNameRe->RegExp.test(options.name)) {
229
+ JsError.throwWithMessage(
230
+ `Invalid effect name "${options.name}". Effect names may contain letters, numbers, underscores, hyphens and dots (but must not start with a dot or contain a path separator), because the name is used as the cache table name and cache file path.`,
231
+ )
232
+ }
212
233
  let outputSchema =
213
234
  S.schema(_ => options.output)->(Utils.magic: S.t<S.t<'output>> => S.t<Internal.effectOutput>)
214
235
  let itemSchema = S.schema((s): Internal.effectCacheItem => {
@@ -222,8 +243,6 @@ let createEffect = (
222
243
  Internal.effectOutput,
223
244
  >
224
245
  ),
225
- activeCallsCount: 0,
226
- prevCallStartTimerRef: %raw(`null`),
227
246
  // This is the way to make the createEffect API
228
247
  // work without the need for users to call S.schema themselves,
229
248
  // but simply pass the desired object/tuple/etc.
@@ -233,7 +252,6 @@ let createEffect = (
233
252
  ),
234
253
  output: outputSchema,
235
254
  storageMeta: {
236
- table: Internal.makeCacheTable(~effectName=options.name),
237
255
  outputSchema,
238
256
  itemSchema,
239
257
  },
@@ -241,16 +259,16 @@ let createEffect = (
241
259
  | Some(true) => true
242
260
  | _ => false
243
261
  },
262
+ crossChain: switch options.crossChain {
263
+ | Some(false) => false
264
+ | _ => true
265
+ },
244
266
  rateLimit: switch options.rateLimit {
245
267
  | Disable => None
246
268
  | Enable({calls, per}) =>
247
269
  Some({
248
270
  callsPerDuration: calls,
249
271
  durationMs: per->durationToMs,
250
- availableCalls: calls,
251
- windowStartTime: Date.now(),
252
- queueCount: 0,
253
- nextWindowPromise: None,
254
272
  })
255
273
  },
256
274
  }->(Utils.magic: Internal.effect => effect<'input, 'output>)
package/src/Envio.res.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  import * as Address from "./Address.res.mjs";
4
4
  import * as Process from "process";
5
- import * as Internal from "./Internal.res.mjs";
6
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
7
  import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
8
8
 
9
9
  function durationToMs(duration) {
@@ -18,41 +18,36 @@ function durationToMs(duration) {
18
18
  }
19
19
  }
20
20
 
21
+ let effectNameRe = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/;
22
+
21
23
  function createEffect(options, handler) {
24
+ if (!effectNameRe.test(options.name)) {
25
+ Stdlib_JsError.throwWithMessage(`Invalid effect name "` + options.name + `". Effect names may contain letters, numbers, underscores, hyphens and dots (but must not start with a dot or contain a path separator), because the name is used as the cache table name and cache file path.`);
26
+ }
22
27
  let outputSchema = S$RescriptSchema.schema(param => options.output);
23
28
  let itemSchema = S$RescriptSchema.schema(s => ({
24
29
  id: s.m(S$RescriptSchema.string),
25
30
  output: s.m(outputSchema)
26
31
  }));
27
32
  let match = options.cache;
28
- let match$1 = options.rateLimit;
33
+ let match$1 = options.crossChain;
34
+ let match$2 = options.rateLimit;
29
35
  let tmp;
30
- if (match$1 === false) {
31
- tmp = undefined;
32
- } else {
33
- let calls = match$1.calls;
34
- tmp = {
35
- callsPerDuration: calls,
36
- durationMs: durationToMs(match$1.per),
37
- availableCalls: calls,
38
- windowStartTime: Date.now(),
39
- queueCount: 0,
40
- nextWindowPromise: undefined
41
- };
42
- }
36
+ tmp = match$2 === false ? undefined : ({
37
+ callsPerDuration: match$2.calls,
38
+ durationMs: durationToMs(match$2.per)
39
+ });
43
40
  return {
44
41
  name: options.name,
45
42
  handler: handler,
46
43
  storageMeta: {
47
44
  itemSchema: itemSchema,
48
- outputSchema: outputSchema,
49
- table: Internal.makeCacheTable(options.name)
45
+ outputSchema: outputSchema
50
46
  },
51
47
  defaultShouldCache: match !== undefined ? match : false,
48
+ crossChain: match$1 !== undefined ? match$1 : true,
52
49
  output: outputSchema,
53
50
  input: S$RescriptSchema.schema(param => options.input),
54
- activeCallsCount: 0,
55
- prevCallStartTimerRef: null,
56
51
  rateLimit: tmp
57
52
  };
58
53
  }
@@ -102,6 +97,7 @@ let TestHelpers = {
102
97
 
103
98
  export {
104
99
  durationToMs,
100
+ effectNameRe,
105
101
  createEffect,
106
102
  isNonInteractive,
107
103
  TestHelpers,
@@ -47,17 +47,19 @@ type partition = {
47
47
  dynamicContract: option<string>,
48
48
  // Mutable array for SourceManager sync - queries exist only while being fetched
49
49
  mutPendingQueries: array<pendingQuery>,
50
- // Track last 3 successful query ranges for chunking heuristic (0 means no data)
51
- prevQueryRange: int,
52
- prevPrevQueryRange: int,
53
- // Item count of the response that set prevQueryRange. Paired with it to derive
54
- // the partition's event density (prevRangeSize / prevQueryRange) for sizing
55
- // queries against the buffer budget.
56
- prevRangeSize: int,
50
+ // The last two ranges that measured how many blocks the source could return.
51
+ // Both must be non-zero before the minimum is trusted for chunk sizing.
52
+ sourceRangeCapacity: int,
53
+ prevSourceRangeCapacity: int,
54
+ // Smoothed items/block observed in responses. This is independent from the
55
+ // source's range capacity: even a response truncated by our own itemsTarget
56
+ // cap is useful density evidence while saying nothing about source capacity.
57
+ // None distinguishes a new partition from a real zero-density observation.
58
+ eventDensity: option<float>,
57
59
  // Tracks the latestFetchedBlock.blockNumber of the most recent response
58
- // that updated prevQueryRange. Prevents degradation of the chunking heuristic
59
- // when parallel query responses arrive out of order.
60
- latestBlockRangeUpdateBlock: int,
60
+ // that updated sourceRangeCapacity. Prevents degradation of the chunking
61
+ // heuristic when parallel query responses arrive out of order.
62
+ latestSourceRangeCapacityUpdateBlock: int,
61
63
  }
62
64
 
63
65
  type query = {
@@ -111,22 +113,17 @@ let densityItemsTarget = (~density, ~fromBlock, ~toBlock, ~chainTargetBlock) =>
111
113
  )
112
114
  }
113
115
 
114
- // Calculate the chunk range from history using min-of-last-3-ranges heuristic
116
+ // Calculate the chunk range from the last two source-capacity observations.
115
117
  let getMinHistoryRange = (p: partition) => {
116
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
118
+ switch (p.sourceRangeCapacity, p.prevSourceRangeCapacity) {
117
119
  | (0, _) | (_, 0) => None
118
120
  | (a, b) => Some(a < b ? a : b)
119
121
  }
120
122
  }
121
123
 
122
- // Density (items/block) from the last response, trusted only after two
123
- // responses a single sample is too noisy to size the next query by.
124
- let getTrustedDensity = (p: partition) => {
125
- switch (p.prevQueryRange, p.prevPrevQueryRange) {
126
- | (0, _) | (_, 0) => None
127
- | (prevQueryRange, _) => Some(p.prevRangeSize->Int.toFloat /. prevQueryRange->Int.toFloat)
128
- }
129
- }
124
+ // Density has its own initialization signal and is useful independently from
125
+ // source-capacity history, including when an itemsTarget cap truncates a response.
126
+ let getTrustedDensity = (p: partition) => p.eventDensity
130
127
 
131
128
  let getMinQueryRange = (partitions: array<partition>) => {
132
129
  let min = ref(0)
@@ -134,8 +131,8 @@ let getMinQueryRange = (partitions: array<partition>) => {
134
131
  let p = partitions->Array.getUnsafe(i)
135
132
 
136
133
  // Even if it's fetching, set dynamicContract field
137
- let a = p.prevQueryRange
138
- let b = p.prevPrevQueryRange
134
+ let a = p.sourceRangeCapacity
135
+ let b = p.prevSourceRangeCapacity
139
136
  if a > 0 && (min.contents == 0 || a < min.contents) {
140
137
  min := a
141
138
  }
@@ -208,11 +205,14 @@ module OptimizedPartitions = {
208
205
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
209
206
  let minRange = getMinQueryRange([p1, p2])
210
207
  // The merged partition indexes both parents' addresses, so its expected
211
- // event rate is the sum of their densities. Parents without a trusted
212
- // density contribute 0; if none has one, prevRangeSize stays 0 and the
213
- // partition probes for a fresh signal instead of chunking.
214
- let inheritedDensity =
215
- p1->getTrustedDensity->Option.getOr(0.) +. p2->getTrustedDensity->Option.getOr(0.)
208
+ // event rate is the sum of their densities. If neither parent has a
209
+ // trusted density, the merged partition probes for a fresh signal
210
+ // instead of treating zero as an observed density.
211
+ let inheritedDensity = switch (p1->getTrustedDensity, p2->getTrustedDensity) {
212
+ | (Some(p1Density), Some(p2Density)) => Some(p1Density +. p2Density)
213
+ | (Some(density), None) | (None, Some(density)) => Some(density)
214
+ | (None, None) => None
215
+ }
216
216
  {
217
217
  id: newId,
218
218
  dynamicContract: Some(contractName),
@@ -221,10 +221,10 @@ module OptimizedPartitions = {
221
221
  mergeBlock: None,
222
222
  addressesByContractName: Dict.make(), // set below
223
223
  mutPendingQueries: [],
224
- prevQueryRange: minRange,
225
- prevPrevQueryRange: minRange,
226
- prevRangeSize: (inheritedDensity *. minRange->Int.toFloat)->Math.ceil->Float.toInt,
227
- latestBlockRangeUpdateBlock: 0,
224
+ sourceRangeCapacity: minRange,
225
+ prevSourceRangeCapacity: minRange,
226
+ eventDensity: inheritedDensity,
227
+ latestSourceRangeCapacityUpdateBlock: 0,
228
228
  }
229
229
  }
230
230
 
@@ -472,14 +472,28 @@ module OptimizedPartitions = {
472
472
  pendingQuery.fetchedBlock = Some(latestFetchedBlock)
473
473
 
474
474
  let blockRange = latestFetchedBlock.blockNumber - query.fromBlock + 1
475
- // Skip updating block range if a later response already updated it.
475
+ // Update density for every response, independently from whether this range
476
+ // is valid evidence of source capacity. A cap hit is still useful density
477
+ // evidence because it reports items returned across the scanned range.
478
+ let observedEventDensity = itemsCount->Int.toFloat /. blockRange->Int.toFloat
479
+ // Seed from the first observation, then smooth every later observation
480
+ // with a 1:1 moving average. Keeping initialization explicit is important:
481
+ // zero is valid density evidence and must participate in the next blend.
482
+ let updatedEventDensity = switch p.eventDensity {
483
+ | None => Some(observedEventDensity)
484
+ | Some(eventDensity) => Some((eventDensity +. observedEventDensity) /. 2.)
485
+ }
486
+
487
+ // Skip updating source capacity if a later response already updated it.
476
488
  // Prevents degradation of the chunking heuristic when parallel query
477
489
  // responses arrive out of order (e.g. earlier query with smaller range
478
490
  // arriving after a later query with bigger range).
479
- let shouldUpdateBlockRange =
480
- latestFetchedBlock.blockNumber > p.latestBlockRangeUpdateBlock &&
491
+ let shouldUpdateSourceRangeCapacity =
492
+ latestFetchedBlock.blockNumber > p.latestSourceRangeCapacityUpdateBlock &&
481
493
  switch query.toBlock {
482
- | None => latestFetchedBlock.blockNumber < knownHeight - 10 // Don't update block range when very close to the head
494
+ | None =>
495
+ // Don't update source capacity when very close to the head.
496
+ latestFetchedBlock.blockNumber < knownHeight - 10
483
497
  | Some(queryToBlock) =>
484
498
  if latestFetchedBlock.blockNumber < queryToBlock {
485
499
  // Partial response is direct capacity evidence — unless it was
@@ -497,9 +511,12 @@ module OptimizedPartitions = {
497
511
  }
498
512
  }
499
513
  }
500
- let updatedPrevQueryRange = shouldUpdateBlockRange ? blockRange : p.prevQueryRange
501
- let updatedPrevPrevQueryRange = shouldUpdateBlockRange ? p.prevQueryRange : p.prevPrevQueryRange
502
- let updatedPrevRangeSize = shouldUpdateBlockRange ? itemsCount : p.prevRangeSize
514
+ let updatedSourceRangeCapacity = shouldUpdateSourceRangeCapacity
515
+ ? blockRange
516
+ : p.sourceRangeCapacity
517
+ let updatedPrevSourceRangeCapacity = shouldUpdateSourceRangeCapacity
518
+ ? p.sourceRangeCapacity
519
+ : p.prevSourceRangeCapacity
503
520
 
504
521
  // Process fetched queries from front of queue for main partition
505
522
  let updatedLatestFetchedBlock = consumeFetchedQueries(
@@ -519,12 +536,12 @@ module OptimizedPartitions = {
519
536
  let updatedMainPartition = {
520
537
  ...p,
521
538
  latestFetchedBlock: updatedLatestFetchedBlock,
522
- prevQueryRange: updatedPrevQueryRange,
523
- prevPrevQueryRange: updatedPrevPrevQueryRange,
524
- prevRangeSize: updatedPrevRangeSize,
525
- latestBlockRangeUpdateBlock: shouldUpdateBlockRange
539
+ sourceRangeCapacity: updatedSourceRangeCapacity,
540
+ prevSourceRangeCapacity: updatedPrevSourceRangeCapacity,
541
+ eventDensity: updatedEventDensity,
542
+ latestSourceRangeCapacityUpdateBlock: shouldUpdateSourceRangeCapacity
526
543
  ? latestFetchedBlock.blockNumber
527
- : p.latestBlockRangeUpdateBlock,
544
+ : p.latestSourceRangeCapacityUpdateBlock,
528
545
  }
529
546
 
530
547
  mutEntities->Dict.set(p.id, updatedMainPartition)
@@ -986,10 +1003,10 @@ OptimizedPartitions.t => {
986
1003
  addressesByContractName,
987
1004
  mergeBlock: None,
988
1005
  mutPendingQueries: [],
989
- prevQueryRange: 0,
990
- prevPrevQueryRange: 0,
991
- prevRangeSize: 0,
992
- latestBlockRangeUpdateBlock: 0,
1006
+ sourceRangeCapacity: 0,
1007
+ prevSourceRangeCapacity: 0,
1008
+ eventDensity: None,
1009
+ latestSourceRangeCapacityUpdateBlock: 0,
993
1010
  })
994
1011
  nextPartitionIndexRef := nextPartitionIndexRef.contents + 1
995
1012
  }
@@ -1322,10 +1339,10 @@ let registerDynamicContracts = (
1322
1339
  addressesByContractName,
1323
1340
  mergeBlock: None,
1324
1341
  mutPendingQueries: p.mutPendingQueries,
1325
- prevQueryRange: p.prevQueryRange,
1326
- prevPrevQueryRange: p.prevPrevQueryRange,
1327
- prevRangeSize: p.prevRangeSize,
1328
- latestBlockRangeUpdateBlock: p.latestBlockRangeUpdateBlock,
1342
+ sourceRangeCapacity: p.sourceRangeCapacity,
1343
+ prevSourceRangeCapacity: p.prevSourceRangeCapacity,
1344
+ eventDensity: p.eventDensity,
1345
+ latestSourceRangeCapacityUpdateBlock: p.latestSourceRangeCapacityUpdateBlock,
1329
1346
  })
1330
1347
  }
1331
1348
  }
@@ -1448,6 +1465,11 @@ let startFetchingQueries = ({optimizedPartitions}: t, ~queries: array<query>) =>
1448
1465
  // Most parallel in-flight chunk queries a single partition may have at once.
1449
1466
  let maxPendingChunksPerPartition = 12
1450
1467
 
1468
+ // Most parallel in-flight queries a single chain may have at once, across all
1469
+ // its partitions. Bounds source load on chains with many partitions, where the
1470
+ // per-partition cap alone would admit thousands of concurrent queries.
1471
+ let maxChainConcurrency = 100
1472
+
1451
1473
  // Generates candidate queries for a gap range (a hole left between
1452
1474
  // completed/pending chunks, e.g. from an out-of-order partial response). Gaps
1453
1475
  // carry the range's low fromBlock, so the acceptance pass takes them before
@@ -1582,24 +1604,25 @@ type partitionFillState = {
1582
1604
  // query with no other ceiling: the true hard bounds stay
1583
1605
  // endBlock/mergeBlock/the lagged head.
1584
1606
  //
1585
- // The tick's budget is chainTargetItems minus what's already in flight. Every
1586
- // candidate query gap-fill holes, plus each in-range partition's chunks or
1587
- // open-ended probe toward the target is generated with no budget check, then
1588
- // the candidates are sorted by fromBlock and accepted in that order while the
1589
- // budget stays positive. The query that tips it negative is still accepted (a
1590
- // single overshoot); everything after it waits for a tick with more budget.
1607
+ // The tick's budget is chainTargetItems minus what's already in flight. A
1608
+ // non-positive budget only resolves the wait action and generates no query
1609
+ // candidates. With a positive budget, every candidate query gap-fill holes,
1610
+ // plus each in-range partition's chunks or open-ended probe toward the target
1611
+ // is generated with no budget check, then the candidates are sorted by
1612
+ // fromBlock and accepted in that order while the budget stays positive. The
1613
+ // query that tips it negative is still accepted (a single overshoot);
1614
+ // everything after it waits for a tick with more budget.
1591
1615
  // Sorting by fromBlock spends the budget on the earliest blocks across all
1592
1616
  // partitions first, so no partition is starved by generation order and the
1593
1617
  // frontier advances evenly. In-flight reservations release as responses land,
1594
1618
  // so acceptance redistributes across ticks.
1595
1619
  //
1596
- // A partition with a trusted positive density (two or more responses — see
1597
- // getMinHistoryRange) generates real, density-sized chunks toward the target.
1598
- // Any other partition (no signal, one noisy sample, or a density-0 estimate)
1599
- // generates one open-ended probe sized to the events its range to the target is
1600
- // expected to hold — rangeTargetDensity × (chainTargetBlock − fromBlock + 1) /
1601
- // inRangeCount — so several unknown-density partitions probe in parallel within
1602
- // one budget, each scaled by how much of the range it still has to cover.
1620
+ // A partition with source-capacity history and a positive density generates
1621
+ // density-sized chunks toward the target. Any other partition (no signal, no
1622
+ // capacity history, or a density-0 estimate) generates one open-ended probe
1623
+ // sized to the events its range to the target is expected to hold —
1624
+ // rangeTargetDensity × (chainTargetBlock − fromBlock + 1) / inRangeCount — so
1625
+ // unknown-density partitions probe in parallel within one budget.
1603
1626
  let getNextQuery = (
1604
1627
  {optimizedPartitions, blockLag, latestOnBlockBlockNumber, knownHeight, endBlock}: t,
1605
1628
  ~chainTargetBlock: int,
@@ -1639,6 +1662,12 @@ let getNextQuery = (
1639
1662
  }
1640
1663
  }
1641
1664
 
1665
+ // A zero budget is an admission check: preserve the wait-state scan above,
1666
+ // but make every query-generation pass below empty. Caught-up chains also
1667
+ // skip those passes because their action is already known.
1668
+ let partitionsCount =
1669
+ chainTargetItems <= 0. || shouldWaitForNewBlock.contents ? 0 : partitionsCount
1670
+
1642
1671
  // One bucket per partition, in idsInAscOrder order — gap-fill and the
1643
1672
  // budget pass both push into a partition's own bucket, so flattening at
1644
1673
  // the end (see below) reproduces idsInAscOrder without a sort.
@@ -1788,10 +1817,9 @@ let getNextQuery = (
1788
1817
  inRangeCount > 0 && rangeToTarget > 0 ? freshBudget /. rangeToTarget->Int.toFloat : 0.
1789
1818
 
1790
1819
  // Phase B: generate each in-range partition's candidates — strict chunks
1791
- // toward the target sized by real density (up to the pending-chunk cap), or,
1792
- // for a partition without a trusted positive density, a single open-ended
1793
- // probe at its even share of the budget. No budget check here; the
1794
- // acceptance pass below decides which candidates make the cut.
1820
+ // when both source-capacity history and density are known, or an open-ended
1821
+ // budget probe otherwise. No budget check here; the acceptance pass below
1822
+ // decides which candidates make the cut.
1795
1823
  //
1796
1824
  // Chunks require a POSITIVE trusted density: density 0 prices every chunk at
1797
1825
  // ~nothing, so an open-ended probe (full server scan range in one response)
@@ -1916,12 +1944,21 @@ let getNextQuery = (
1916
1944
  let streamCount = acceptanceStream->Array.length
1917
1945
  let remainingBudget = ref(chainTargetItems)
1918
1946
  let acceptIdx = ref(0)
1919
- while remainingBudget.contents > 0. && acceptIdx.contents < streamCount {
1947
+ // In-flight queries count against the chain's concurrency cap alongside the
1948
+ // ones accepted this tick; once the cap is reached no later candidate can be
1949
+ // accepted (they're only later in fromBlock order), so the walk stops.
1950
+ let usedConcurrency = ref(reservations->Array.length)
1951
+ while (
1952
+ remainingBudget.contents > 0. &&
1953
+ acceptIdx.contents < streamCount &&
1954
+ usedConcurrency.contents < maxChainConcurrency
1955
+ ) {
1920
1956
  let (_, itemsEst, maybeQuery) = acceptanceStream->Array.getUnsafe(acceptIdx.contents)
1921
1957
  switch maybeQuery {
1922
1958
  | Some(query) =>
1923
1959
  let partitionIdx = partitionIndexById->Dict.getUnsafe(query.partitionId)
1924
1960
  queriesByPartitionIndex->Array.getUnsafe(partitionIdx)->Array.push(query)->ignore
1961
+ usedConcurrency := usedConcurrency.contents + 1
1925
1962
  | None => ()
1926
1963
  }
1927
1964
  remainingBudget := remainingBudget.contents -. itemsEst->Int.toFloat
@@ -2022,10 +2059,10 @@ let make = (
2022
2059
  mergeBlock: None,
2023
2060
  dynamicContract: None,
2024
2061
  mutPendingQueries: [],
2025
- prevQueryRange: 0,
2026
- prevPrevQueryRange: 0,
2027
- prevRangeSize: 0,
2028
- latestBlockRangeUpdateBlock: 0,
2062
+ sourceRangeCapacity: 0,
2063
+ prevSourceRangeCapacity: 0,
2064
+ eventDensity: None,
2065
+ latestSourceRangeCapacityUpdateBlock: 0,
2029
2066
  })
2030
2067
  }
2031
2068
 
@@ -2345,13 +2382,15 @@ let isReadyToEnterReorgThreshold = ({endBlock, blockLag, buffer, knownHeight} as
2345
2382
  buffer->Utils.Array.isEmpty
2346
2383
  }
2347
2384
 
2348
- // Lower progress percentage = further behind = higher priority. Shared by the
2349
- // batch ordering and the cross-chain fetch priority.
2385
+ // Lower progress percentage = further behind = higher priority. Progress is
2386
+ // relative to the head this chain can actually fetch, so a chain at its lagged
2387
+ // head does not look behind relative to unavailable blocks. Shared by the batch
2388
+ // ordering and the cross-chain fetch priority.
2350
2389
  let getProgressPercentage = (fetchState: t) => {
2351
2390
  switch fetchState.firstEventBlock {
2352
2391
  | None => 0.
2353
2392
  | Some(firstEventBlock) =>
2354
- let totalRange = fetchState.knownHeight - firstEventBlock
2393
+ let totalRange = fetchState.knownHeight - fetchState.blockLag - firstEventBlock
2355
2394
  if totalRange <= 0 {
2356
2395
  0.
2357
2396
  } else {