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
package/index.d.ts CHANGED
@@ -48,6 +48,13 @@ export type EffectCaller = <I, O>(
48
48
  input: I extends undefined ? undefined : I
49
49
  ) => Promise<O>;
50
50
 
51
+ /** The chain an Effect was called on. Available only on chain-scoped effects
52
+ * (`crossChain: false`). */
53
+ export type EffectChain = {
54
+ /** The chain id the effect handler was called on. */
55
+ readonly id: number;
56
+ };
57
+
51
58
  /** Context passed to an Effect's handler function. */
52
59
  export type EffectContext = {
53
60
  /** Access the logger instance with the event as context. */
@@ -57,6 +64,9 @@ export type EffectContext = {
57
64
  /** Whether to cache this call's result. Defaults to the effect's `cache`
58
65
  * option; set to `false` to skip caching for this specific invocation. */
59
66
  cache: boolean;
67
+ /** The chain the effect was called on. Only available on effects created with
68
+ * `crossChain: false`; accessing it on a cross-chain effect throws. */
69
+ readonly chain: EffectChain;
60
70
  };
61
71
 
62
72
  /** Rate-limit window for an {@link Effect}. Strings resolve to common
@@ -82,6 +92,11 @@ export type EffectOptions<Input, Output> = {
82
92
  readonly rateLimit: RateLimit;
83
93
  /** Whether the effect should be cached. */
84
94
  readonly cache?: boolean;
95
+ /** Whether the effect's cache is shared across all chains. Defaults to
96
+ * `true`. Set to `false` to isolate the cache and rate limiting per chain and
97
+ * enable `context.chain.id` inside the handler. Changing this changes the
98
+ * effect's cache identity. */
99
+ readonly crossChain?: boolean;
85
100
  };
86
101
 
87
102
  /** Arguments passed to the handler function of an {@link Effect}. */
@@ -209,6 +224,10 @@ export function createEffect<
209
224
  readonly rateLimit: RateLimit;
210
225
  /** Whether the effect should be cached. */
211
226
  readonly cache?: boolean;
227
+ /** Whether the effect's cache is shared across all chains. Defaults to
228
+ * `true`. Set to `false` to isolate the cache and rate limiting per chain
229
+ * and enable `context.chain.id` inside the handler. */
230
+ readonly crossChain?: boolean;
212
231
  },
213
232
  handler: (args: EffectArgs<I>) => Promise<R>
214
233
  ): Effect<I, O>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envio",
3
- "version": "3.3.0-alpha.11",
3
+ "version": "3.3.0-alpha.13",
4
4
  "type": "module",
5
5
  "description": "A latency and sync speed optimized, developer friendly blockchain data indexer.",
6
6
  "bin": "./bin.mjs",
@@ -70,10 +70,10 @@
70
70
  "tsx": "4.21.0"
71
71
  },
72
72
  "optionalDependencies": {
73
- "envio-linux-x64": "3.3.0-alpha.11",
74
- "envio-linux-x64-musl": "3.3.0-alpha.11",
75
- "envio-linux-arm64": "3.3.0-alpha.11",
76
- "envio-darwin-x64": "3.3.0-alpha.11",
77
- "envio-darwin-arm64": "3.3.0-alpha.11"
73
+ "envio-linux-x64": "3.3.0-alpha.13",
74
+ "envio-linux-x64-musl": "3.3.0-alpha.13",
75
+ "envio-linux-arm64": "3.3.0-alpha.13",
76
+ "envio-darwin-x64": "3.3.0-alpha.13",
77
+ "envio-darwin-arm64": "3.3.0-alpha.13"
78
78
  }
79
79
  }
@@ -149,19 +149,28 @@ let rec onQueryResponse = async (
149
149
 
150
150
  let numContractRegisterEvents = parsedQueueItems->Array.reduce(0, (count, item) => {
151
151
  let eventItem = item->Internal.castUnsafeEventItem
152
- eventItem.onEventRegistration.contractRegister !== None
153
- ? count + 1
154
- : count
155
- })
156
- Logging.trace({
157
- "msg": "Finished querying",
158
- "chainId": chain->ChainMap.Chain.toChainId,
159
- "partitionId": query.partitionId,
160
- "fromBlock": fromBlockQueried,
161
- "toBlock": latestFetchedBlockNumber,
162
- "numEvents": parsedQueueItems->Array.length,
163
- "numContractRegisterEvents": numContractRegisterEvents,
152
+ eventItem.onEventRegistration.contractRegister !== None ? count + 1 : count
164
153
  })
154
+ if numContractRegisterEvents === 0 {
155
+ Logging.trace({
156
+ "msg": "Finished querying",
157
+ "chainId": chain->ChainMap.Chain.toChainId,
158
+ "partitionId": query.partitionId,
159
+ "fromBlock": fromBlockQueried,
160
+ "toBlock": latestFetchedBlockNumber,
161
+ "numEvents": parsedQueueItems->Array.length,
162
+ })
163
+ } else {
164
+ Logging.trace({
165
+ "msg": "Finished querying",
166
+ "chainId": chain->ChainMap.Chain.toChainId,
167
+ "partitionId": query.partitionId,
168
+ "fromBlock": fromBlockQueried,
169
+ "toBlock": latestFetchedBlockNumber,
170
+ "numEvents": parsedQueueItems->Array.length,
171
+ "numContractRegisterEvents": numContractRegisterEvents,
172
+ })
173
+ }
165
174
 
166
175
  let reorgResult = chainState->ChainState.registerReorgGuard(~blockHashes, ~knownHeight)
167
176
 
@@ -111,15 +111,26 @@ async function onQueryResponse(state, param, stateId, scheduleFetch, schedulePro
111
111
  return count;
112
112
  }
113
113
  });
114
- Logging.trace({
115
- msg: "Finished querying",
116
- chainId: chain,
117
- partitionId: query.partitionId,
118
- fromBlock: fromBlockQueried,
119
- toBlock: latestFetchedBlockNumber,
120
- numEvents: parsedQueueItems.length,
121
- numContractRegisterEvents: numContractRegisterEvents
122
- });
114
+ if (numContractRegisterEvents === 0) {
115
+ Logging.trace({
116
+ msg: "Finished querying",
117
+ chainId: chain,
118
+ partitionId: query.partitionId,
119
+ fromBlock: fromBlockQueried,
120
+ toBlock: latestFetchedBlockNumber,
121
+ numEvents: parsedQueueItems.length
122
+ });
123
+ } else {
124
+ Logging.trace({
125
+ msg: "Finished querying",
126
+ chainId: chain,
127
+ partitionId: query.partitionId,
128
+ fromBlock: fromBlockQueried,
129
+ toBlock: latestFetchedBlockNumber,
130
+ numEvents: parsedQueueItems.length,
131
+ numContractRegisterEvents: numContractRegisterEvents
132
+ });
133
+ }
123
134
  let reorgResult = ChainState.registerReorgGuard(chainState, response.blockHashes, knownHeight);
124
135
  let rollbackWithReorgDetectedBlockNumber;
125
136
  if (typeof reorgResult !== "object") {
@@ -410,13 +410,17 @@ let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
410
410
  // nudge it.
411
411
  let densityBlendWindow = 100.
412
412
 
413
- // The last block this chain can fetch right now: the head, or endBlock when
414
- // it's below the head.
413
+ // The last block this chain can fetch right now: the lagged head
414
+ // (knownHeight - blockLag, the frontier when a reorg-threshold blockLag holds
415
+ // back the tip), or endBlock when it's below that head. Matching isFetchingAtHead's
416
+ // definition of the head is what lets a chain parked at its lagged head read
417
+ // 100% progress instead of looking behind against blocks it can't fetch.
415
418
  let fetchCeiling = (cs: t) => {
416
419
  let fetchState = cs.fetchState
420
+ let head = Pervasives.max(0, fetchState.knownHeight - fetchState.blockLag)
417
421
  switch fetchState.endBlock {
418
- | Some(endBlock) => Pervasives.min(endBlock, fetchState.knownHeight)
419
- | None => fetchState.knownHeight
422
+ | Some(endBlock) => Pervasives.min(endBlock, head)
423
+ | None => head
420
424
  }
421
425
  }
422
426
 
@@ -466,12 +470,14 @@ let targetBlock = (cs: t, ~chainTargetItems: float) => {
466
470
  }
467
471
 
468
472
  // Block range that cross-chain progress alignment maps fractions over: from
469
- // the first block that can hold this chain's events to the last block it can
470
- // fetch right now.
473
+ // the chain's startBlock to the last block it can fetch right now. The lower
474
+ // bound is deliberately static — anchoring it at firstEventBlock would make
475
+ // two chains sitting at the same block read different progress fractions
476
+ // depending on whether each has discovered its first event yet, so the
477
+ // anchor's line could map below a follower's own frontier and stall it.
471
478
  let progressRange = (cs: t) => {
472
479
  let fetchState = cs.fetchState
473
- let lower = fetchState.firstEventBlock->Option.getOr(fetchState.startBlock)
474
- (lower, cs->fetchCeiling)
480
+ (fetchState.startBlock, cs->fetchCeiling)
475
481
  }
476
482
 
477
483
  // A degenerate range (chain already at or past its last block) maps to 1 so it
@@ -492,6 +498,12 @@ let blockAtProgress = (cs: t, ~progress) => {
492
498
  lower + Math.ceil(progress *. (upper - lower)->Int.toFloat)->Float.toInt
493
499
  }
494
500
 
501
+ // The fetch frontier as a fraction of the alignable range — what the
502
+ // cross-chain waterfall aligns other chains against. Based on blocks actually
503
+ // fetched, so it holds up even while this chain's queries are in flight.
504
+ let frontierProgress = (cs: t) =>
505
+ cs->progressAtBlock(~blockNumber=cs.fetchState->FetchState.bufferBlockNumber)
506
+
495
507
  // Propose queries sized against this chain's target block. Called by
496
508
  // CrossChainState's waterfall, furthest-behind chain first, with
497
509
  // chainTargetItems set to whatever budget remains at that point and
@@ -254,11 +254,12 @@ function isReady(cs) {
254
254
 
255
255
  function fetchCeiling(cs) {
256
256
  let fetchState = cs.fetchState;
257
+ let head = Primitive_int.max(0, fetchState.knownHeight - fetchState.blockLag | 0);
257
258
  let endBlock = fetchState.endBlock;
258
259
  if (endBlock !== undefined) {
259
- return Primitive_int.min(endBlock, fetchState.knownHeight);
260
+ return Primitive_int.min(endBlock, head);
260
261
  } else {
261
- return fetchState.knownHeight;
262
+ return head;
262
263
  }
263
264
  }
264
265
 
@@ -300,14 +301,20 @@ function targetBlock(cs, chainTargetItems) {
300
301
 
301
302
  function progressRange(cs) {
302
303
  let fetchState = cs.fetchState;
303
- let lower = Stdlib_Option.getOr(fetchState.firstEventBlock, fetchState.startBlock);
304
304
  return [
305
- lower,
305
+ fetchState.startBlock,
306
306
  fetchCeiling(cs)
307
307
  ];
308
308
  }
309
309
 
310
- function progressAtBlock(cs, blockNumber) {
310
+ function blockAtProgress(cs, progress) {
311
+ let match = progressRange(cs);
312
+ let lower = match[0];
313
+ return lower + (Math.ceil(progress * (match[1] - lower | 0)) | 0) | 0;
314
+ }
315
+
316
+ function frontierProgress(cs) {
317
+ let blockNumber = FetchState.bufferBlockNumber(cs.fetchState);
311
318
  let match = progressRange(cs);
312
319
  let upper = match[1];
313
320
  let lower = match[0];
@@ -318,12 +325,6 @@ function progressAtBlock(cs, blockNumber) {
318
325
  }
319
326
  }
320
327
 
321
- function blockAtProgress(cs, progress) {
322
- let match = progressRange(cs);
323
- let lower = match[0];
324
- return lower + (Math.ceil(progress * (match[1] - lower | 0)) | 0) | 0;
325
- }
326
-
327
328
  function getNextQuery(cs, chainTargetItems, chunkItemsMultiplierOpt, maxTargetBlock) {
328
329
  let chunkItemsMultiplier = chunkItemsMultiplierOpt !== undefined ? chunkItemsMultiplierOpt : 1;
329
330
  let chainTargetBlock = targetBlock(cs, chainTargetItems);
@@ -782,8 +783,8 @@ export {
782
783
  hasReadyItem,
783
784
  isReadyToEnterReorgThreshold,
784
785
  targetBlock,
785
- progressAtBlock,
786
786
  blockAtProgress,
787
+ frontierProgress,
787
788
  getNextQuery,
788
789
  dispatch,
789
790
  toChainData,
@@ -67,8 +67,8 @@ let isReadyToEnterReorgThreshold: t => bool
67
67
 
68
68
  // Fetch control.
69
69
  let targetBlock: (t, ~chainTargetItems: float) => int
70
- let progressAtBlock: (t, ~blockNumber: int) => float
71
70
  let blockAtProgress: (t, ~progress: float) => int
71
+ let frontierProgress: t => float
72
72
  let getNextQuery: (
73
73
  t,
74
74
  ~chainTargetItems: float,
@@ -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
@@ -198,69 +203,88 @@ let totalReservedSize = (crossChainState: t) => {
198
203
  // chain-density-derived target block, then subtract what it actually used
199
204
  // before moving to the next chain. So a chain that can only use a little
200
205
  // (density too low, or already caught up) leaves the rest for the others
201
- // automatically. A chain visited after the budget ran out simply doesn't
202
- // query this round the leader's reservations release as its responses land,
203
- // so the next tick redistributes. Chains that do get budget are additionally
204
- // capped at the most-behind chain's target progress mapped onto their own
205
- // range, so no chain runs further ahead than the chain the pool is
206
- // prioritizing.
206
+ // automatically. Starting a new query requires at least 10% of the target pool
207
+ // to be free. A chain visited after the budget falls below that admission unit
208
+ // doesn't query this round reservations release as responses land, so the
209
+ // next tick redistributes. Every other chain is additionally capped at the
210
+ // lowest-frontier-progress chain's progress mapped onto its own range, so no
211
+ // chain runs ahead of the chain the pool is prioritizing — including on ticks
212
+ // where that chain is mid-fetch and emits no new query. A chain with no known
213
+ // height can't anchor this line (there's no range to measure against), a chain
214
+ // caught up to its fetchable head reads 100% and so never anchors while another
215
+ // is behind, and once the whole indexer has caught up (isRealtime) the clamp is
216
+ // dropped — chains at head only trail each other by real-time block production.
207
217
  let checkAndFetch = async (
208
218
  crossChainState: t,
209
219
  ~dispatchChain: (~chain: ChainMap.Chain.t, ~action: FetchState.nextQuery) => promise<unit>,
210
220
  ) => {
221
+ let targetBudget = crossChainState.targetBufferSize->Int.toFloat
211
222
  let remaining = ref(
212
223
  Pervasives.max(
213
224
  0.,
214
- crossChainState.targetBufferSize->Int.toFloat -.
225
+ targetBudget -.
215
226
  crossChainState->totalReadyCount->Int.toFloat -.
216
227
  crossChainState->totalReservedSize,
217
228
  ),
218
229
  )
219
230
 
231
+ // New fetch work is admitted in units of 10% of the target pool. Waiting
232
+ // below this floor avoids spending the last few free items on undersized
233
+ // queries; response and batch completion schedule another tick after they
234
+ // release enough budget.
235
+ let minimumAdmissionBudget = targetBudget *. 0.1
236
+
220
237
  // A chain with no density signal probes blind, so it only gets a bounded
221
238
  // slice of the pool — one unknown chain shouldn't hold the whole budget
222
- // while it takes its first measurements.
223
- let coldChainBudget = Pervasives.min(5_000., crossChainState.targetBufferSize->Int.toFloat)
239
+ // while it takes its first measurements. Its probe is one admission unit.
240
+ let coldChainBudget = minimumAdmissionBudget
224
241
 
225
242
  // Chunk reservations get headroom over the density estimate so a
226
243
  // denser-than-expected range doesn't truncate at the server cap; realtime
227
244
  // gets more since a forced catch-up query there costs a head-poll roundtrip.
228
245
  let chunkItemsMultiplier = crossChainState.isRealtime ? 3. : 1.5
229
246
 
247
+ let prioritizedChainStates = crossChainState->priorityOrder
248
+
249
+ // Alignment anchor: the first known-height chain in priority order — which,
250
+ // since that order sorts by frontier progress, is the chain furthest behind
251
+ // by the very metric the clamp maps other chains against. Anchoring on the
252
+ // frontier (not on the target of whichever chain happens to query this tick)
253
+ // keeps the line in place while the anchor's queries are still in flight. A
254
+ // chain caught up to its fetchable head reads 100% and sorts past any behind
255
+ // chain, so it never anchors while another chain is behind.
256
+ let alignment = crossChainState.isRealtime
257
+ ? None
258
+ : prioritizedChainStates
259
+ ->Array.find(cs => cs->ChainState.knownHeight != 0)
260
+ ->Option.map(cs => ((cs->ChainState.chainConfig).id, cs->ChainState.frontierProgress))
261
+
230
262
  let actionByChain = Dict.make()
231
- let maxProgress = ref(None)
232
- crossChainState
233
- ->priorityOrder
234
- ->Array.forEach(cs => {
263
+ prioritizedChainStates->Array.forEach(cs => {
235
264
  let chainId = (cs->ChainState.chainConfig).id
236
- if cs->ChainState.knownHeight == 0 {
237
- // No height yet nothing to size a budget or an alignment line
238
- // against. Skip without consuming budget or claiming leadership, so a
239
- // chain whose source hasn't reported doesn't unconstrain everyone else.
240
- actionByChain->Utils.Dict.setByInt(chainId, FetchState.WaitingForNewBlock)
265
+ if remaining.contents < minimumAdmissionBudget {
266
+ // More than 90% of the target pool is ready or reserved. Do not attempt
267
+ // any chain action until a full admission unit becomes free.
268
+ actionByChain->Utils.Dict.setByInt(chainId, FetchState.NothingToQuery)
269
+ } else if cs->ChainState.knownHeight == 0 {
270
+ // Let getNextQuery own the waiting decision without trying to size work
271
+ // against an unknown height.
272
+ actionByChain->Utils.Dict.setByInt(
273
+ chainId,
274
+ cs->ChainState.getNextQuery(~chainTargetItems=0., ~chunkItemsMultiplier),
275
+ )
241
276
  } else {
242
277
  let isCold = cs->ChainState.effectiveDensity === None
243
278
  let chainTargetItems =
244
279
  (isCold ? Pervasives.min(remaining.contents, coldChainBudget) : remaining.contents) +.
245
280
  cs->ChainState.pendingBudget
246
- let maxTargetBlock = switch maxProgress.contents {
247
- | None if isCold =>
248
- // A cold chain's target is a guess it doesn't set the alignment
249
- // line; the first density-bearing chain after it does.
250
- None
251
- | None =>
252
- // The most-behind chain sets the alignment line for everyone after it.
253
- maxProgress :=
254
- Some(
255
- cs->ChainState.progressAtBlock(
256
- ~blockNumber=cs->ChainState.targetBlock(~chainTargetItems),
257
- ),
258
- )
259
- None
260
- // 5% margin past the leader's line: chains whose progress tracks the
261
- // leader closely would otherwise flap in and out of the clamp as
262
- // leadership alternates, stalling their pipeline every other tick.
263
- | Some(progress) => Some(cs->ChainState.blockAtProgress(~progress=progress +. 0.05))
281
+ let maxTargetBlock = switch alignment {
282
+ // 5% margin past the anchor's line: chains whose progress tracks the
283
+ // anchor closely would otherwise flap in and out of the clamp on every
284
+ // small frontier move, stalling their pipeline every other tick.
285
+ | Some((anchorChainId, progress)) if anchorChainId !== chainId =>
286
+ Some(cs->ChainState.blockAtProgress(~progress=progress +. 0.05))
287
+ | _ => None
264
288
  }
265
289
  switch cs->ChainState.getNextQuery(
266
290
  ~chainTargetItems,
@@ -274,8 +298,7 @@ let checkAndFetch = async (
274
298
  // range to nothing — and has nothing else to wake it must keep polling
275
299
  // for new blocks instead of going silent. NothingToQuery isn't
276
300
  // dispatched, so such a chain would never be re-scheduled and its head
277
- // tracking would freeze (the knownHeight == 0 branch above forces a poll
278
- // for the same reason). A chain is genuinely idle — and correctly left
301
+ // tracking would freeze. A chain is genuinely idle and correctly left
279
302
  // undispatched — when it is caught up to its head/endblock, still
280
303
  // draining in-flight queries, or holding ready items that batch
281
304
  // processing will drain and re-schedule from.
@@ -8,6 +8,7 @@ import * as ChainMap from "./ChainMap.res.mjs";
8
8
  import * as ChainState from "./ChainState.res.mjs";
9
9
  import * as Prometheus from "./Prometheus.res.mjs";
10
10
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
11
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
11
12
  import * as Primitive_float from "@rescript/runtime/lib/es6/Primitive_float.js";
12
13
  import * as SafeCheckpointTracking from "./SafeCheckpointTracking.res.mjs";
13
14
 
@@ -121,7 +122,7 @@ function applyBatchProgress(crossChainState, batch, blockTimestampName) {
121
122
  }
122
123
 
123
124
  function priorityOrder(crossChainState) {
124
- return Object.values(crossChainState.chainStates).toSorted((a, b) => Primitive_float.compare(ChainState.getProgressPercentage(a), ChainState.getProgressPercentage(b)));
125
+ return Object.values(crossChainState.chainStates).toSorted((a, b) => Primitive_float.compare(ChainState.frontierProgress(a), ChainState.frontierProgress(b)));
125
126
  }
126
127
 
127
128
  function totalReservedSize(crossChainState) {
@@ -134,29 +135,33 @@ function totalReservedSize(crossChainState) {
134
135
  }
135
136
 
136
137
  async function checkAndFetch(crossChainState, dispatchChain) {
138
+ let targetBudget = crossChainState.targetBufferSize;
137
139
  let remaining = {
138
- contents: Primitive_float.max(0, crossChainState.targetBufferSize - totalReadyCount(crossChainState) - totalReservedSize(crossChainState))
140
+ contents: Primitive_float.max(0, targetBudget - totalReadyCount(crossChainState) - totalReservedSize(crossChainState))
139
141
  };
140
- let coldChainBudget = Primitive_float.min(5000, crossChainState.targetBufferSize);
142
+ let minimumAdmissionBudget = targetBudget * 0.1;
141
143
  let chunkItemsMultiplier = crossChainState.isRealtime ? 3 : 1.5;
144
+ let prioritizedChainStates = priorityOrder(crossChainState);
145
+ let alignment = crossChainState.isRealtime ? undefined : Stdlib_Option.map(prioritizedChainStates.find(cs => ChainState.knownHeight(cs) !== 0), cs => [
146
+ ChainState.chainConfig(cs).id,
147
+ ChainState.frontierProgress(cs)
148
+ ]);
142
149
  let actionByChain = {};
143
- let maxProgress = {
144
- contents: undefined
145
- };
146
- priorityOrder(crossChainState).forEach(cs => {
150
+ prioritizedChainStates.forEach(cs => {
147
151
  let chainId = ChainState.chainConfig(cs).id;
152
+ if (remaining.contents < minimumAdmissionBudget) {
153
+ actionByChain[chainId] = "NothingToQuery";
154
+ return;
155
+ }
148
156
  if (ChainState.knownHeight(cs) === 0) {
149
- actionByChain[chainId] = "WaitingForNewBlock";
157
+ actionByChain[chainId] = ChainState.getNextQuery(cs, 0, chunkItemsMultiplier, undefined);
150
158
  return;
151
159
  }
152
160
  let isCold = ChainState.effectiveDensity(cs) === undefined;
153
161
  let chainTargetItems = (
154
- isCold ? Primitive_float.min(remaining.contents, coldChainBudget) : remaining.contents
162
+ isCold ? Primitive_float.min(remaining.contents, minimumAdmissionBudget) : remaining.contents
155
163
  ) + ChainState.pendingBudget(cs);
156
- let progress = maxProgress.contents;
157
- let maxTargetBlock = progress !== undefined ? ChainState.blockAtProgress(cs, progress + 0.05) : (
158
- isCold ? undefined : (maxProgress.contents = ChainState.progressAtBlock(cs, ChainState.targetBlock(cs, chainTargetItems)), undefined)
159
- );
164
+ let maxTargetBlock = alignment !== undefined && alignment[0] !== chainId ? ChainState.blockAtProgress(cs, alignment[1] + 0.05) : undefined;
160
165
  let action = ChainState.getNextQuery(cs, chainTargetItems, chunkItemsMultiplier, maxTargetBlock);
161
166
  if (typeof action !== "object") {
162
167
  if (action === "WaitingForNewBlock") {
@@ -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()