envio 3.3.0-alpha.9 → 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 (94) 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 +134 -55
  13. package/src/ChainState.res.mjs +78 -36
  14. package/src/ChainState.resi +14 -2
  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 +115 -34
  20. package/src/CrossChainState.res.mjs +37 -14
  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/EventConfigBuilder.res +21 -46
  27. package/src/EventConfigBuilder.res.mjs +18 -25
  28. package/src/EventProcessing.res +8 -5
  29. package/src/EventProcessing.res.mjs +2 -1
  30. package/src/FetchState.res +814 -467
  31. package/src/FetchState.res.mjs +541 -369
  32. package/src/HandlerRegister.res +3 -1
  33. package/src/HandlerRegister.res.mjs +3 -2
  34. package/src/InMemoryStore.res +14 -26
  35. package/src/InMemoryStore.res.mjs +6 -19
  36. package/src/IndexerState.res +15 -39
  37. package/src/IndexerState.res.mjs +18 -29
  38. package/src/IndexerState.resi +2 -10
  39. package/src/Internal.res +98 -14
  40. package/src/Internal.res.mjs +96 -2
  41. package/src/LoadLayer.res +44 -24
  42. package/src/LoadLayer.res.mjs +43 -20
  43. package/src/LoadLayer.resi +1 -0
  44. package/src/LogSelection.res +0 -62
  45. package/src/LogSelection.res.mjs +0 -74
  46. package/src/Metrics.res +1 -1
  47. package/src/Metrics.res.mjs +1 -1
  48. package/src/Persistence.res +12 -3
  49. package/src/PgStorage.res +155 -82
  50. package/src/PgStorage.res.mjs +130 -77
  51. package/src/Prometheus.res +20 -10
  52. package/src/Prometheus.res.mjs +22 -10
  53. package/src/PruneStaleHistory.res +146 -35
  54. package/src/PruneStaleHistory.res.mjs +114 -20
  55. package/src/RawEvent.res +2 -2
  56. package/src/Rollback.res +32 -13
  57. package/src/Rollback.res.mjs +24 -11
  58. package/src/SimulateDeadInputTracker.res +1 -1
  59. package/src/SimulateItems.res +38 -6
  60. package/src/SimulateItems.res.mjs +28 -9
  61. package/src/TestIndexer.res +2 -2
  62. package/src/TestIndexer.res.mjs +2 -2
  63. package/src/TopicFilter.res +1 -25
  64. package/src/TopicFilter.res.mjs +0 -74
  65. package/src/UserContext.res +62 -23
  66. package/src/UserContext.res.mjs +26 -6
  67. package/src/Writing.res +60 -21
  68. package/src/Writing.res.mjs +27 -9
  69. package/src/bindings/NodeJs.res +5 -0
  70. package/src/bindings/Viem.res +0 -5
  71. package/src/sources/EventRouter.res +0 -21
  72. package/src/sources/EventRouter.res.mjs +0 -12
  73. package/src/sources/EvmChain.res +2 -27
  74. package/src/sources/EvmChain.res.mjs +2 -23
  75. package/src/sources/EvmRpcClient.res +58 -23
  76. package/src/sources/EvmRpcClient.res.mjs +11 -5
  77. package/src/sources/HyperSync.res +9 -38
  78. package/src/sources/HyperSync.res.mjs +16 -28
  79. package/src/sources/HyperSync.resi +2 -2
  80. package/src/sources/HyperSyncClient.res +88 -11
  81. package/src/sources/HyperSyncClient.res.mjs +39 -6
  82. package/src/sources/HyperSyncSource.res +18 -199
  83. package/src/sources/HyperSyncSource.res.mjs +9 -128
  84. package/src/sources/Rpc.res +0 -32
  85. package/src/sources/Rpc.res.mjs +1 -46
  86. package/src/sources/RpcSource.res +129 -522
  87. package/src/sources/RpcSource.res.mjs +161 -366
  88. package/src/sources/SimulateSource.res +37 -19
  89. package/src/sources/SimulateSource.res.mjs +27 -10
  90. package/src/sources/SourceManager.res +3 -7
  91. package/src/sources/SourceManager.res.mjs +2 -7
  92. package/src/sources/SourceManager.resi +0 -2
  93. package/src/sources/SvmHyperSyncSource.res +13 -3
  94. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
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,57 +219,117 @@ 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. 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.
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.
207
233
  let checkAndFetch = async (
208
234
  crossChainState: t,
209
235
  ~dispatchChain: (~chain: ChainMap.Chain.t, ~action: FetchState.nextQuery) => promise<unit>,
210
236
  ) => {
237
+ let targetBudget = crossChainState.targetBufferSize->Int.toFloat
211
238
  let remaining = ref(
212
239
  Pervasives.max(
213
240
  0.,
214
- crossChainState.targetBufferSize->Int.toFloat -.
241
+ targetBudget -.
215
242
  crossChainState->totalReadyCount->Int.toFloat -.
216
243
  crossChainState->totalReservedSize,
217
244
  ),
218
245
  )
219
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
+
220
286
  let actionByChain = Dict.make()
221
- let maxProgress = ref(None)
222
- crossChainState
223
- ->priorityOrder
224
- ->Array.forEach(cs => {
287
+ prioritizedChainStates->Array.forEach(cs => {
225
288
  let chainId = (cs->ChainState.chainConfig).id
226
289
  if cs->ChainState.knownHeight == 0 {
227
- // No height yet — nothing to size a budget or an alignment line
228
- // against. Skip without consuming budget or claiming leadership, so a
229
- // chain whose source hasn't reported doesn't unconstrain everyone else.
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.)
230
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)
231
304
  } else {
232
- let chainTargetItems = remaining.contents +. cs->ChainState.pendingBudget
233
- let maxTargetBlock = switch maxProgress.contents {
234
- | None =>
235
- // The most-behind chain sets the alignment line for everyone after it.
236
- maxProgress :=
237
- Some(
238
- cs->ChainState.progressAtBlock(
239
- ~blockNumber=cs->ChainState.targetBlock(~chainTargetItems),
240
- ),
241
- )
242
- None
243
- | Some(progress) => Some(cs->ChainState.blockAtProgress(~progress))
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
244
316
  }
245
- switch cs->ChainState.getNextQuery(~chainTargetItems, ~maxTargetBlock?) {
246
- | (WaitingForNewBlock | NothingToQuery) as action =>
247
- actionByChain->Utils.Dict.setByInt(chainId, action)
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))
248
329
  | Ready(queries) => {
249
330
  let consumed =
250
331
  queries->Array.reduce(0., (acc, query: FetchState.query) =>
251
- acc +. query.itemsTarget->Int.toFloat
332
+ acc +. query.itemsEst->Int.toFloat
252
333
  )
253
334
 
254
335
  let partitions = Dict.make()
@@ -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,34 +136,54 @@ 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
- let maxProgress = {
142
- contents: undefined
143
- };
144
- priorityOrder(crossChainState).forEach(cs => {
161
+ prioritizedChainStates.forEach(cs => {
145
162
  let chainId = ChainState.chainConfig(cs).id;
146
163
  if (ChainState.knownHeight(cs) === 0) {
147
164
  actionByChain[chainId] = "WaitingForNewBlock";
148
165
  return;
149
166
  }
150
- let chainTargetItems = remaining.contents + ChainState.pendingBudget(cs);
151
- let progress = maxProgress.contents;
152
- let maxTargetBlock = progress !== undefined ? ChainState.blockAtProgress(cs, progress) : (maxProgress.contents = ChainState.progressAtBlock(cs, ChainState.targetBlock(cs, chainTargetItems)), undefined);
153
- let action = ChainState.getNextQuery(cs, chainTargetItems, maxTargetBlock);
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);
154
177
  if (typeof action !== "object") {
155
178
  if (action === "WaitingForNewBlock") {
156
179
  actionByChain[chainId] = action;
157
180
  return;
158
181
  }
159
- actionByChain[chainId] = action;
182
+ actionByChain[chainId] = idleOrWaitAction(cs);
160
183
  return;
161
184
  } else {
162
185
  let queries = action._0;
163
- 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);
164
187
  let partitions = {};
165
188
  queries.forEach(query => {
166
189
  partitions[query.partitionId] = {
@@ -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 */
@@ -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>)