envio 3.3.0-alpha.12 → 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.
- package/index.d.ts +19 -0
- package/package.json +6 -6
- package/src/ChainState.res +20 -8
- package/src/ChainState.res.mjs +13 -12
- package/src/ChainState.resi +1 -1
- package/src/CrossChainState.res +38 -33
- package/src/CrossChainState.res.mjs +9 -12
- 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/FetchState.res +15 -1
- package/src/FetchState.res.mjs +6 -1
- package/src/InMemoryStore.res +14 -26
- package/src/InMemoryStore.res.mjs +6 -19
- package/src/IndexerState.res +10 -19
- package/src/IndexerState.res.mjs +7 -6
- package/src/IndexerState.resi +1 -9
- package/src/Internal.res +87 -12
- 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/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/UserContext.res +62 -23
- package/src/UserContext.res.mjs +26 -6
- package/src/Writing.res +28 -12
- package/src/Writing.res.mjs +15 -7
- package/src/bindings/NodeJs.res +5 -0
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.
|
|
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.
|
|
74
|
-
"envio-linux-x64-musl": "3.3.0-alpha.
|
|
75
|
-
"envio-linux-arm64": "3.3.0-alpha.
|
|
76
|
-
"envio-darwin-x64": "3.3.0-alpha.
|
|
77
|
-
"envio-darwin-arm64": "3.3.0-alpha.
|
|
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
|
}
|
package/src/ChainState.res
CHANGED
|
@@ -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
|
|
414
|
-
//
|
|
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,
|
|
419
|
-
| None =>
|
|
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
|
|
470
|
-
//
|
|
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
|
-
|
|
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
|
package/src/ChainState.res.mjs
CHANGED
|
@@ -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,
|
|
260
|
+
return Primitive_int.min(endBlock, head);
|
|
260
261
|
} else {
|
|
261
|
-
return
|
|
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
|
-
|
|
305
|
+
fetchState.startBlock,
|
|
306
306
|
fetchCeiling(cs)
|
|
307
307
|
];
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
function
|
|
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,
|
package/src/ChainState.resi
CHANGED
|
@@ -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,
|
package/src/CrossChainState.res
CHANGED
|
@@ -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 fetchable 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
|
|
@@ -201,10 +206,14 @@ let totalReservedSize = (crossChainState: t) => {
|
|
|
201
206
|
// automatically. Starting a new query requires at least 10% of the target pool
|
|
202
207
|
// to be free. A chain visited after the budget falls below that admission unit
|
|
203
208
|
// doesn't query this round — reservations release as responses land, so the
|
|
204
|
-
// next tick redistributes.
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// and
|
|
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.
|
|
208
217
|
let checkAndFetch = async (
|
|
209
218
|
crossChainState: t,
|
|
210
219
|
~dispatchChain: (~chain: ChainMap.Chain.t, ~action: FetchState.nextQuery) => promise<unit>,
|
|
@@ -235,11 +244,23 @@ let checkAndFetch = async (
|
|
|
235
244
|
// gets more since a forced catch-up query there costs a head-poll roundtrip.
|
|
236
245
|
let chunkItemsMultiplier = crossChainState.isRealtime ? 3. : 1.5
|
|
237
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
|
+
|
|
238
262
|
let actionByChain = Dict.make()
|
|
239
|
-
|
|
240
|
-
crossChainState
|
|
241
|
-
->priorityOrder
|
|
242
|
-
->Array.forEach(cs => {
|
|
263
|
+
prioritizedChainStates->Array.forEach(cs => {
|
|
243
264
|
let chainId = (cs->ChainState.chainConfig).id
|
|
244
265
|
if remaining.contents < minimumAdmissionBudget {
|
|
245
266
|
// More than 90% of the target pool is ready or reserved. Do not attempt
|
|
@@ -257,22 +278,14 @@ let checkAndFetch = async (
|
|
|
257
278
|
let chainTargetItems =
|
|
258
279
|
(isCold ? Pervasives.min(remaining.contents, coldChainBudget) : remaining.contents) +.
|
|
259
280
|
cs->ChainState.pendingBudget
|
|
260
|
-
let
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
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))
|
|
267
287
|
| _ => None
|
|
268
288
|
}
|
|
269
|
-
let maxTargetBlock = switch maxProgress.contents {
|
|
270
|
-
| None => None
|
|
271
|
-
// 5% margin past the leader's line: chains whose progress tracks the
|
|
272
|
-
// leader closely would otherwise flap in and out of the clamp as
|
|
273
|
-
// leadership alternates, stalling their pipeline every other tick.
|
|
274
|
-
| Some(progress) => Some(cs->ChainState.blockAtProgress(~progress=progress +. 0.05))
|
|
275
|
-
}
|
|
276
289
|
switch cs->ChainState.getNextQuery(
|
|
277
290
|
~chainTargetItems,
|
|
278
291
|
~chunkItemsMultiplier,
|
|
@@ -297,14 +310,6 @@ let checkAndFetch = async (
|
|
|
297
310
|
: FetchState.WaitingForNewBlock
|
|
298
311
|
actionByChain->Utils.Dict.setByInt(chainId, action)
|
|
299
312
|
| Ready(queries) => {
|
|
300
|
-
// A density-bearing chain establishes the alignment line only after
|
|
301
|
-
// proving that it can use the pool. Waiting and idle chains leave it
|
|
302
|
-
// open for the next chain; cold-chain targets remain guesses and do
|
|
303
|
-
// not lead, as before.
|
|
304
|
-
switch candidateProgress {
|
|
305
|
-
| Some(progress) => maxProgress := Some(progress)
|
|
306
|
-
| None => ()
|
|
307
|
-
}
|
|
308
313
|
let consumed =
|
|
309
314
|
queries->Array.reduce(0., (acc, query: FetchState.query) =>
|
|
310
315
|
acc +. query.itemsEst->Int.toFloat
|
|
@@ -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.
|
|
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) {
|
|
@@ -140,11 +141,13 @@ async function checkAndFetch(crossChainState, dispatchChain) {
|
|
|
140
141
|
};
|
|
141
142
|
let minimumAdmissionBudget = targetBudget * 0.1;
|
|
142
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
|
+
]);
|
|
143
149
|
let actionByChain = {};
|
|
144
|
-
|
|
145
|
-
contents: undefined
|
|
146
|
-
};
|
|
147
|
-
priorityOrder(crossChainState).forEach(cs => {
|
|
150
|
+
prioritizedChainStates.forEach(cs => {
|
|
148
151
|
let chainId = ChainState.chainConfig(cs).id;
|
|
149
152
|
if (remaining.contents < minimumAdmissionBudget) {
|
|
150
153
|
actionByChain[chainId] = "NothingToQuery";
|
|
@@ -158,10 +161,7 @@ async function checkAndFetch(crossChainState, dispatchChain) {
|
|
|
158
161
|
let chainTargetItems = (
|
|
159
162
|
isCold ? Primitive_float.min(remaining.contents, minimumAdmissionBudget) : remaining.contents
|
|
160
163
|
) + ChainState.pendingBudget(cs);
|
|
161
|
-
let
|
|
162
|
-
let candidateProgress = match !== undefined || isCold ? undefined : ChainState.progressAtBlock(cs, ChainState.targetBlock(cs, chainTargetItems));
|
|
163
|
-
let progress = maxProgress.contents;
|
|
164
|
-
let maxTargetBlock = progress !== undefined ? ChainState.blockAtProgress(cs, progress + 0.05) : undefined;
|
|
164
|
+
let maxTargetBlock = alignment !== undefined && alignment[0] !== chainId ? ChainState.blockAtProgress(cs, alignment[1] + 0.05) : undefined;
|
|
165
165
|
let action = ChainState.getNextQuery(cs, chainTargetItems, chunkItemsMultiplier, maxTargetBlock);
|
|
166
166
|
if (typeof action !== "object") {
|
|
167
167
|
if (action === "WaitingForNewBlock") {
|
|
@@ -173,9 +173,6 @@ async function checkAndFetch(crossChainState, dispatchChain) {
|
|
|
173
173
|
return;
|
|
174
174
|
} else {
|
|
175
175
|
let queries = action._0;
|
|
176
|
-
if (candidateProgress !== undefined) {
|
|
177
|
-
maxProgress.contents = candidateProgress;
|
|
178
|
-
}
|
|
179
176
|
let consumed = Stdlib_Array.reduce(queries, 0, (acc, query) => acc + query.itemsEst);
|
|
180
177
|
let partitions = {};
|
|
181
178
|
queries.forEach(query => {
|
|
@@ -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>)
|