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.
- package/index.d.ts +19 -0
- package/licenses/CLA.md +35 -0
- package/licenses/EULA.md +67 -0
- package/licenses/LICENSE.md +45 -0
- package/licenses/README.md +35 -0
- package/package.json +9 -8
- package/src/Batch.res.mjs +1 -1
- package/src/BatchProcessing.res +0 -7
- package/src/BatchProcessing.res.mjs +1 -8
- package/src/ChainFetching.res +30 -18
- package/src/ChainFetching.res.mjs +23 -13
- package/src/ChainState.res +134 -55
- package/src/ChainState.res.mjs +78 -36
- package/src/ChainState.resi +14 -2
- package/src/Config.res +9 -5
- package/src/Config.res.mjs +2 -2
- package/src/Core.res +20 -0
- package/src/Core.res.mjs +12 -0
- package/src/CrossChainState.res +115 -34
- package/src/CrossChainState.res.mjs +37 -14
- package/src/EffectState.res +100 -0
- package/src/EffectState.res.mjs +74 -0
- package/src/EffectState.resi +32 -0
- package/src/Envio.res +25 -7
- package/src/Envio.res.mjs +15 -19
- package/src/EventConfigBuilder.res +21 -46
- package/src/EventConfigBuilder.res.mjs +18 -25
- package/src/EventProcessing.res +8 -5
- package/src/EventProcessing.res.mjs +2 -1
- package/src/FetchState.res +814 -467
- package/src/FetchState.res.mjs +541 -369
- package/src/HandlerRegister.res +3 -1
- package/src/HandlerRegister.res.mjs +3 -2
- package/src/InMemoryStore.res +14 -26
- package/src/InMemoryStore.res.mjs +6 -19
- package/src/IndexerState.res +15 -39
- package/src/IndexerState.res.mjs +18 -29
- package/src/IndexerState.resi +2 -10
- package/src/Internal.res +98 -14
- package/src/Internal.res.mjs +96 -2
- package/src/LoadLayer.res +44 -24
- package/src/LoadLayer.res.mjs +43 -20
- package/src/LoadLayer.resi +1 -0
- package/src/LogSelection.res +0 -62
- package/src/LogSelection.res.mjs +0 -74
- package/src/Metrics.res +1 -1
- package/src/Metrics.res.mjs +1 -1
- package/src/Persistence.res +12 -3
- package/src/PgStorage.res +155 -82
- package/src/PgStorage.res.mjs +130 -77
- package/src/Prometheus.res +20 -10
- package/src/Prometheus.res.mjs +22 -10
- package/src/PruneStaleHistory.res +146 -35
- package/src/PruneStaleHistory.res.mjs +114 -20
- package/src/RawEvent.res +2 -2
- package/src/Rollback.res +32 -13
- package/src/Rollback.res.mjs +24 -11
- package/src/SimulateDeadInputTracker.res +1 -1
- package/src/SimulateItems.res +38 -6
- package/src/SimulateItems.res.mjs +28 -9
- package/src/TestIndexer.res +2 -2
- package/src/TestIndexer.res.mjs +2 -2
- package/src/TopicFilter.res +1 -25
- package/src/TopicFilter.res.mjs +0 -74
- package/src/UserContext.res +62 -23
- package/src/UserContext.res.mjs +26 -6
- package/src/Writing.res +60 -21
- package/src/Writing.res.mjs +27 -9
- package/src/bindings/NodeJs.res +5 -0
- package/src/bindings/Viem.res +0 -5
- package/src/sources/EventRouter.res +0 -21
- package/src/sources/EventRouter.res.mjs +0 -12
- package/src/sources/EvmChain.res +2 -27
- package/src/sources/EvmChain.res.mjs +2 -23
- package/src/sources/EvmRpcClient.res +58 -23
- package/src/sources/EvmRpcClient.res.mjs +11 -5
- package/src/sources/HyperSync.res +9 -38
- package/src/sources/HyperSync.res.mjs +16 -28
- package/src/sources/HyperSync.resi +2 -2
- package/src/sources/HyperSyncClient.res +88 -11
- package/src/sources/HyperSyncClient.res.mjs +39 -6
- package/src/sources/HyperSyncSource.res +18 -199
- package/src/sources/HyperSyncSource.res.mjs +9 -128
- package/src/sources/Rpc.res +0 -32
- package/src/sources/Rpc.res.mjs +1 -46
- package/src/sources/RpcSource.res +129 -522
- package/src/sources/RpcSource.res.mjs +161 -366
- package/src/sources/SimulateSource.res +37 -19
- package/src/sources/SimulateSource.res.mjs +27 -10
- package/src/sources/SourceManager.res +3 -7
- package/src/sources/SourceManager.res.mjs +2 -7
- package/src/sources/SourceManager.resi +0 -2
- package/src/sources/SvmHyperSyncSource.res +13 -3
- 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 */
|
package/src/CrossChainState.res
CHANGED
|
@@ -19,7 +19,7 @@ type t = {
|
|
|
19
19
|
let calculateTargetBufferSize = () =>
|
|
20
20
|
switch Env.targetBufferSize {
|
|
21
21
|
| Some(size) => size
|
|
22
|
-
| None =>
|
|
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
|
|
173
|
-
// chains with the most backfill work
|
|
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.
|
|
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.
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
//
|
|
229
|
-
//
|
|
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
|
|
233
|
-
let
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
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
|
|
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 :
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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] =
|
|
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.
|
|
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>)
|