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 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.12",
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.12",
74
- "envio-linux-x64-musl": "3.3.0-alpha.12",
75
- "envio-linux-arm64": "3.3.0-alpha.12",
76
- "envio-darwin-x64": "3.3.0-alpha.12",
77
- "envio-darwin-arm64": "3.3.0-alpha.12"
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
  }
@@ -410,13 +410,17 @@ let isReady = (cs: t) => cs.timestampCaughtUpToHeadOrEndblock !== None
410
410
  // nudge it.
411
411
  let densityBlendWindow = 100.
412
412
 
413
- // The last block this chain can fetch right now: the head, or endBlock when
414
- // it's below the head.
413
+ // The last block this chain can fetch right now: the lagged head
414
+ // (knownHeight - blockLag, the frontier when a reorg-threshold blockLag holds
415
+ // back the tip), or endBlock when it's below that head. Matching isFetchingAtHead's
416
+ // definition of the head is what lets a chain parked at its lagged head read
417
+ // 100% progress instead of looking behind against blocks it can't fetch.
415
418
  let fetchCeiling = (cs: t) => {
416
419
  let fetchState = cs.fetchState
420
+ let head = Pervasives.max(0, fetchState.knownHeight - fetchState.blockLag)
417
421
  switch fetchState.endBlock {
418
- | Some(endBlock) => Pervasives.min(endBlock, fetchState.knownHeight)
419
- | None => fetchState.knownHeight
422
+ | Some(endBlock) => Pervasives.min(endBlock, head)
423
+ | None => head
420
424
  }
421
425
  }
422
426
 
@@ -466,12 +470,14 @@ let targetBlock = (cs: t, ~chainTargetItems: float) => {
466
470
  }
467
471
 
468
472
  // Block range that cross-chain progress alignment maps fractions over: from
469
- // the first block that can hold this chain's events to the last block it can
470
- // fetch right now.
473
+ // the chain's startBlock to the last block it can fetch right now. The lower
474
+ // bound is deliberately static — anchoring it at firstEventBlock would make
475
+ // two chains sitting at the same block read different progress fractions
476
+ // depending on whether each has discovered its first event yet, so the
477
+ // anchor's line could map below a follower's own frontier and stall it.
471
478
  let progressRange = (cs: t) => {
472
479
  let fetchState = cs.fetchState
473
- let lower = fetchState.firstEventBlock->Option.getOr(fetchState.startBlock)
474
- (lower, cs->fetchCeiling)
480
+ (fetchState.startBlock, cs->fetchCeiling)
475
481
  }
476
482
 
477
483
  // A degenerate range (chain already at or past its last block) maps to 1 so it
@@ -492,6 +498,12 @@ let blockAtProgress = (cs: t, ~progress) => {
492
498
  lower + Math.ceil(progress *. (upper - lower)->Int.toFloat)->Float.toInt
493
499
  }
494
500
 
501
+ // The fetch frontier as a fraction of the alignable range — what the
502
+ // cross-chain waterfall aligns other chains against. Based on blocks actually
503
+ // fetched, so it holds up even while this chain's queries are in flight.
504
+ let frontierProgress = (cs: t) =>
505
+ cs->progressAtBlock(~blockNumber=cs.fetchState->FetchState.bufferBlockNumber)
506
+
495
507
  // Propose queries sized against this chain's target block. Called by
496
508
  // CrossChainState's waterfall, furthest-behind chain first, with
497
509
  // chainTargetItems set to whatever budget remains at that point and
@@ -254,11 +254,12 @@ function isReady(cs) {
254
254
 
255
255
  function fetchCeiling(cs) {
256
256
  let fetchState = cs.fetchState;
257
+ let head = Primitive_int.max(0, fetchState.knownHeight - fetchState.blockLag | 0);
257
258
  let endBlock = fetchState.endBlock;
258
259
  if (endBlock !== undefined) {
259
- return Primitive_int.min(endBlock, fetchState.knownHeight);
260
+ return Primitive_int.min(endBlock, head);
260
261
  } else {
261
- return fetchState.knownHeight;
262
+ return head;
262
263
  }
263
264
  }
264
265
 
@@ -300,14 +301,20 @@ function targetBlock(cs, chainTargetItems) {
300
301
 
301
302
  function progressRange(cs) {
302
303
  let fetchState = cs.fetchState;
303
- let lower = Stdlib_Option.getOr(fetchState.firstEventBlock, fetchState.startBlock);
304
304
  return [
305
- lower,
305
+ fetchState.startBlock,
306
306
  fetchCeiling(cs)
307
307
  ];
308
308
  }
309
309
 
310
- function progressAtBlock(cs, blockNumber) {
310
+ function blockAtProgress(cs, progress) {
311
+ let match = progressRange(cs);
312
+ let lower = match[0];
313
+ return lower + (Math.ceil(progress * (match[1] - lower | 0)) | 0) | 0;
314
+ }
315
+
316
+ function frontierProgress(cs) {
317
+ let blockNumber = FetchState.bufferBlockNumber(cs.fetchState);
311
318
  let match = progressRange(cs);
312
319
  let upper = match[1];
313
320
  let lower = match[0];
@@ -318,12 +325,6 @@ function progressAtBlock(cs, blockNumber) {
318
325
  }
319
326
  }
320
327
 
321
- function blockAtProgress(cs, progress) {
322
- let match = progressRange(cs);
323
- let lower = match[0];
324
- return lower + (Math.ceil(progress * (match[1] - lower | 0)) | 0) | 0;
325
- }
326
-
327
328
  function getNextQuery(cs, chainTargetItems, chunkItemsMultiplierOpt, maxTargetBlock) {
328
329
  let chunkItemsMultiplier = chunkItemsMultiplierOpt !== undefined ? chunkItemsMultiplierOpt : 1;
329
330
  let chainTargetBlock = targetBlock(cs, chainTargetItems);
@@ -782,8 +783,8 @@ export {
782
783
  hasReadyItem,
783
784
  isReadyToEnterReorgThreshold,
784
785
  targetBlock,
785
- progressAtBlock,
786
786
  blockAtProgress,
787
+ frontierProgress,
787
788
  getNextQuery,
788
789
  dispatch,
789
790
  toChainData,
@@ -67,8 +67,8 @@ let isReadyToEnterReorgThreshold: t => bool
67
67
 
68
68
  // Fetch control.
69
69
  let targetBlock: (t, ~chainTargetItems: float) => int
70
- let progressAtBlock: (t, ~blockNumber: int) => float
71
70
  let blockAtProgress: (t, ~progress: float) => int
71
+ let frontierProgress: t => float
72
72
  let getNextQuery: (
73
73
  t,
74
74
  ~chainTargetItems: float,
@@ -169,13 +169,18 @@ let applyBatchProgress = (crossChainState: t, ~batch: Batch.t, ~blockTimestampNa
169
169
 
170
170
  // --- Fetch control. ---
171
171
 
172
- // Chains ordered furthest-behind first, so the shared buffer pool goes to the
173
- // chains with the most fetchable 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
@@ -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. Chains that do get budget are additionally capped
205
- // at the first querying chain's target progress mapped onto their own range, so
206
- // no chain runs further ahead than the chain currently using the pool. Waiting
207
- // and idle chains do not establish this alignment line.
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
- let maxProgress = ref(None)
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 candidateProgress = switch maxProgress.contents {
261
- | None if !isCold =>
262
- Some(
263
- cs->ChainState.progressAtBlock(
264
- ~blockNumber=cs->ChainState.targetBlock(~chainTargetItems),
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.getProgressPercentage(a), ChainState.getProgressPercentage(b)));
125
+ return Object.values(crossChainState.chainStates).toSorted((a, b) => Primitive_float.compare(ChainState.frontierProgress(a), ChainState.frontierProgress(b)));
125
126
  }
126
127
 
127
128
  function totalReservedSize(crossChainState) {
@@ -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
- let maxProgress = {
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 match = maxProgress.contents;
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>)