envio 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/evm.schema.json +26 -0
  2. package/fuel.schema.json +26 -0
  3. package/index.d.ts +35 -3
  4. package/package.json +6 -7
  5. package/src/Batch.res +9 -0
  6. package/src/ChainState.res +35 -39
  7. package/src/ChainState.res.mjs +14 -11
  8. package/src/ChainState.resi +15 -7
  9. package/src/Config.res +52 -44
  10. package/src/Config.res.mjs +51 -17
  11. package/src/Core.res +4 -0
  12. package/src/CrossChainState.res +15 -23
  13. package/src/CrossChainState.res.mjs +10 -18
  14. package/src/CrossChainState.resi +4 -1
  15. package/src/Ecosystem.res +7 -2
  16. package/src/Envio.res +6 -4
  17. package/src/EventConfigBuilder.res +169 -26
  18. package/src/EventConfigBuilder.res.mjs +124 -15
  19. package/src/HandlerRegister.res +21 -4
  20. package/src/HandlerRegister.res.mjs +24 -5
  21. package/src/InMemoryStore.res +9 -7
  22. package/src/InMemoryStore.res.mjs +3 -3
  23. package/src/IndexerState.res +43 -6
  24. package/src/IndexerState.res.mjs +36 -7
  25. package/src/IndexerState.resi +4 -4
  26. package/src/Internal.res +16 -17
  27. package/src/Metrics.res +72 -6
  28. package/src/Metrics.res.mjs +30 -2
  29. package/src/Persistence.res +36 -30
  30. package/src/Persistence.res.mjs +1 -20
  31. package/src/PgStorage.res +104 -56
  32. package/src/PgStorage.res.mjs +57 -35
  33. package/src/PruneStaleHistory.res +85 -52
  34. package/src/PruneStaleHistory.res.mjs +63 -37
  35. package/src/Rollback.res +68 -45
  36. package/src/Rollback.res.mjs +38 -19
  37. package/src/RollbackCommit.res +7 -9
  38. package/src/RollbackCommit.res.mjs +5 -6
  39. package/src/SafeCheckpointTracking.res +11 -11
  40. package/src/SafeCheckpointTracking.res.mjs +7 -6
  41. package/src/SimulateItems.res +33 -18
  42. package/src/SimulateItems.res.mjs +26 -22
  43. package/src/Sink.res +38 -26
  44. package/src/Sink.res.mjs +21 -16
  45. package/src/TestIndexer.res +5 -5
  46. package/src/TestIndexer.res.mjs +1 -1
  47. package/src/Utils.res +170 -0
  48. package/src/Utils.res.mjs +153 -0
  49. package/src/Writing.res +2 -2
  50. package/src/Writing.res.mjs +1 -1
  51. package/src/bindings/ClickHouse.res +366 -698
  52. package/src/bindings/ClickHouse.res.mjs +371 -421
  53. package/src/bindings/ClickHouseSink.res +337 -0
  54. package/src/bindings/ClickHouseSink.res.mjs +246 -0
  55. package/src/bindings/EventSource.res +8 -2
  56. package/src/bindings/NodeJs.res +6 -0
  57. package/src/bindings/Vitest.res +23 -0
  58. package/src/bindings/Vitest.res.mjs +3 -0
  59. package/src/bindings/WebSocket.res +9 -10
  60. package/src/db/CheckpointBounds.res +53 -0
  61. package/src/db/CheckpointBounds.res.mjs +44 -0
  62. package/src/db/EntityFilter.res +36 -17
  63. package/src/db/EntityFilter.res.mjs +25 -14
  64. package/src/db/EntityHistory.res +47 -22
  65. package/src/db/EntityHistory.res.mjs +19 -12
  66. package/src/db/InternalTable.res +84 -35
  67. package/src/db/InternalTable.res.mjs +56 -23
  68. package/src/db/RollbackFloors.res +55 -0
  69. package/src/db/RollbackFloors.res.mjs +92 -0
  70. package/src/db/Table.res +22 -4
  71. package/src/db/Table.res.mjs +30 -9
  72. package/src/sources/Evm.res +2 -0
  73. package/src/sources/Evm.res.mjs +2 -0
  74. package/src/sources/EvmHyperSyncSource.res +3 -3
  75. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  76. package/src/sources/EvmRpcWs.res +124 -0
  77. package/src/sources/EvmRpcWs.res.mjs +117 -0
  78. package/src/sources/Fuel.res +2 -0
  79. package/src/sources/Fuel.res.mjs +2 -0
  80. package/src/sources/FuelHyperSyncSource.res +1 -1
  81. package/src/sources/FuelHyperSyncSource.res.mjs +2 -1
  82. package/src/sources/HeightFeed.res +568 -0
  83. package/src/sources/HeightFeed.res.mjs +452 -0
  84. package/src/sources/HeightStream.res +257 -0
  85. package/src/sources/HeightStream.res.mjs +194 -0
  86. package/src/sources/HyperSync.res +5 -0
  87. package/src/sources/HyperSync.res.mjs +3 -0
  88. package/src/sources/HyperSync.resi +4 -0
  89. package/src/sources/HyperSyncSSE.res +49 -0
  90. package/src/sources/HyperSyncSSE.res.mjs +79 -0
  91. package/src/sources/RpcSource.res +1 -1
  92. package/src/sources/RpcSource.res.mjs +2 -2
  93. package/src/sources/Source.res +54 -1
  94. package/src/sources/Source.res.mjs +25 -0
  95. package/src/sources/SourceManager.res +220 -227
  96. package/src/sources/SourceManager.res.mjs +139 -164
  97. package/src/sources/SourceManager.resi +8 -0
  98. package/src/sources/Svm.res +2 -0
  99. package/src/sources/Svm.res.mjs +2 -0
  100. package/src/sources/SvmHyperSyncClient.res +70 -127
  101. package/src/sources/SvmHyperSyncClient.res.mjs +51 -25
  102. package/src/sources/SvmHyperSyncSource.res +18 -15
  103. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  104. package/svm.schema.json +1 -75
  105. package/src/MemoryStorage.res +0 -729
  106. package/src/MemoryStorage.res.mjs +0 -586
  107. package/src/sources/HyperSyncHeightStream.res +0 -129
  108. package/src/sources/HyperSyncHeightStream.res.mjs +0 -110
  109. package/src/sources/RpcWebSocketHeightStream.res +0 -175
  110. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -180
@@ -0,0 +1,452 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Utils from "../Utils.res.mjs";
4
+ import * as Source from "./Source.res.mjs";
5
+ import * as Logging from "../Logging.res.mjs";
6
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
9
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
11
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
12
+
13
+ function make(source, recordRequestStats, getHeightRetryInterval) {
14
+ return {
15
+ source: source,
16
+ logger: Logging.createChild({
17
+ chainId: source.chainId,
18
+ source: source.name
19
+ }),
20
+ recordRequestStats: recordRequestStats,
21
+ getHeightRetryInterval: getHeightRetryInterval,
22
+ knownHeight: 0,
23
+ waiters: [],
24
+ stream: "NeverEnabled",
25
+ generation: 0,
26
+ closeStream: undefined,
27
+ polling: false,
28
+ sleeping: undefined,
29
+ pollRetry: 0,
30
+ connects: 0,
31
+ disconnects: {},
32
+ stopped: false,
33
+ unreadableWarned: false,
34
+ subscribeRejectedWarned: false
35
+ };
36
+ }
37
+
38
+ function knownHeight(feed) {
39
+ return feed.knownHeight;
40
+ }
41
+
42
+ function sample(feed) {
43
+ let match = feed.stream;
44
+ if (typeof match !== "object" && match === "NeverEnabled") {
45
+ return;
46
+ }
47
+ return {
48
+ connectCount: feed.connects,
49
+ disconnectsByReason: Object.entries(feed.disconnects).toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0]))
50
+ };
51
+ }
52
+
53
+ function shouldPoll(feed) {
54
+ if (feed.waiters.length === 0) {
55
+ return false;
56
+ }
57
+ let match = feed.stream;
58
+ if (typeof match !== "object" || !match.proven) {
59
+ return true;
60
+ } else {
61
+ return feed.waiters.some(waiter => waiter.distrustsStream);
62
+ }
63
+ }
64
+
65
+ function wakeCadence(feed) {
66
+ let match = feed.sleeping;
67
+ if (match === undefined) {
68
+ return;
69
+ }
70
+ let match$1 = match.kind;
71
+ if (match$1 === "Cadence") {
72
+ return match.wake();
73
+ }
74
+ }
75
+
76
+ function clearBackoff(feed) {
77
+ feed.pollRetry = 0;
78
+ let sleeping = feed.sleeping;
79
+ if (sleeping !== undefined) {
80
+ sleeping.kind = "Cadence";
81
+ return;
82
+ }
83
+ }
84
+
85
+ function fireWaiters(feed, height) {
86
+ let isSatisfied = waiter => height > waiter.knownHeight;
87
+ let satisfied = feed.waiters.filter(isSatisfied);
88
+ if (satisfied.length !== 0) {
89
+ feed.waiters = feed.waiters.filter(waiter => height <= waiter.knownHeight);
90
+ satisfied.forEach(waiter => {
91
+ try {
92
+ return waiter.onHeight(height);
93
+ } catch (raw_exn) {
94
+ let exn = Primitive_exceptions.internalToException(raw_exn);
95
+ return Logging.childError(feed.logger, {
96
+ msg: "A height waiter threw. Dropping it rather than losing the height for the others.",
97
+ err: Utils.prettifyExn(exn)
98
+ });
99
+ }
100
+ });
101
+ return wakeCadence(feed);
102
+ }
103
+ }
104
+
105
+ function trustStreamAgain(feed) {
106
+ feed.waiters.forEach(waiter => {
107
+ waiter.distrustsStream = false;
108
+ });
109
+ }
110
+
111
+ function recordHeight(feed, height) {
112
+ if (height > feed.knownHeight) {
113
+ feed.knownHeight = height;
114
+ return fireWaiters(feed, height);
115
+ }
116
+ }
117
+
118
+ function recordAnswer(feed, generation, res) {
119
+ feed.recordRequestStats(res.requestStats);
120
+ clearBackoff(feed);
121
+ let match = feed.stream;
122
+ if (typeof match !== "object") {
123
+ match === "NeverEnabled";
124
+ } else if (generation === feed.generation) {
125
+ feed.stream = {
126
+ TAG: "Connected",
127
+ proven: true
128
+ };
129
+ }
130
+ recordHeight(feed, res.height);
131
+ wakeCadence(feed);
132
+ }
133
+
134
+ function recordLateAnswer(feed, res) {
135
+ feed.recordRequestStats(res.requestStats);
136
+ recordHeight(feed, res.height);
137
+ }
138
+
139
+ function heightWithin(feed) {
140
+ let timeoutId = {
141
+ contents: undefined
142
+ };
143
+ let timedOut = {
144
+ contents: false
145
+ };
146
+ let request = feed.source.getHeightOrThrow();
147
+ Stdlib_Promise.$$catch(request.then(res => {
148
+ if (timedOut.contents) {
149
+ return recordLateAnswer(feed, res);
150
+ }
151
+ }), param => Promise.resolve());
152
+ return Promise.race([
153
+ Stdlib_Promise.$$catch(request.then(res => ({
154
+ TAG: "Answered",
155
+ _0: res
156
+ })), exn => Promise.resolve({
157
+ TAG: "Failed",
158
+ _0: exn
159
+ })),
160
+ new Promise((resolve, _reject) => {
161
+ timeoutId.contents = Primitive_option.some(setTimeout(() => {
162
+ timedOut.contents = true;
163
+ resolve("TimedOut");
164
+ }, 60000));
165
+ })
166
+ ]).then(outcome => {
167
+ Utils.clearTimeoutRef(timeoutId);
168
+ return outcome;
169
+ });
170
+ }
171
+
172
+ function sleep(feed, kind, millis) {
173
+ return new Promise((resolve, _reject) => {
174
+ let timeoutId = setTimeout(() => {
175
+ feed.sleeping = undefined;
176
+ resolve();
177
+ }, millis);
178
+ feed.sleeping = {
179
+ kind: kind,
180
+ wake: () => {
181
+ clearTimeout(timeoutId);
182
+ feed.sleeping = undefined;
183
+ resolve();
184
+ }
185
+ };
186
+ });
187
+ }
188
+
189
+ function currentInterval(feed) {
190
+ return Stdlib_Option.getOr(Stdlib_Array.reduce(feed.waiters, undefined, (shortest, waiter) => Utils.$$Math.minOptInt(shortest, waiter.interval())), feed.source.pollingInterval);
191
+ }
192
+
193
+ function nextRetryInterval(feed) {
194
+ let retryInterval = feed.getHeightRetryInterval(feed.pollRetry);
195
+ feed.pollRetry = feed.pollRetry + 1 | 0;
196
+ return retryInterval;
197
+ }
198
+
199
+ function backOff(feed, what, exn) {
200
+ let retryInterval = nextRetryInterval(feed);
201
+ Logging.childTrace(feed.logger, {
202
+ msg: `Height retrieval from ` + feed.source.name + ` source ` + what + `. Retrying in ` + retryInterval.toString() + `ms.`,
203
+ err: Stdlib_Option.map(exn, Utils.prettifyExn)
204
+ });
205
+ return [
206
+ retryInterval,
207
+ "Backoff"
208
+ ];
209
+ }
210
+
211
+ async function pollOnce(feed) {
212
+ let generation = feed.generation;
213
+ let res = await heightWithin(feed);
214
+ if (typeof res !== "object") {
215
+ return backOff(feed, `did not answer within ` + (60).toString() + `s`, undefined);
216
+ }
217
+ if (res.TAG !== "Answered") {
218
+ return backOff(feed, "failed", res._0);
219
+ }
220
+ recordAnswer(feed, generation, res._0);
221
+ return [
222
+ currentInterval(feed),
223
+ "Cadence"
224
+ ];
225
+ }
226
+
227
+ async function runPollLoop(feed) {
228
+ while (shouldPoll(feed)) {
229
+ let match;
230
+ try {
231
+ match = await pollOnce(feed);
232
+ } catch (raw_exn) {
233
+ let exn = Primitive_exceptions.internalToException(raw_exn);
234
+ Logging.childError(feed.logger, {
235
+ msg: "The height poll cycle threw. Backing off and carrying on rather than leaving the source uncovered.",
236
+ err: Utils.prettifyExn(exn)
237
+ });
238
+ match = [
239
+ nextRetryInterval(feed),
240
+ "Backoff"
241
+ ];
242
+ }
243
+ if (shouldPoll(feed)) {
244
+ await sleep(feed, match[1], match[0]);
245
+ }
246
+ };
247
+ feed.polling = false;
248
+ }
249
+
250
+ function syncPolling(feed) {
251
+ if (shouldPoll(feed) && !feed.polling) {
252
+ feed.polling = true;
253
+ runPollLoop(feed);
254
+ return;
255
+ } else {
256
+ return wakeCadence(feed);
257
+ }
258
+ }
259
+
260
+ async function catchUpWhileIdle(feed) {
261
+ let generation = feed.generation;
262
+ let res = await heightWithin(feed);
263
+ if (typeof res !== "object" || res.TAG !== "Answered") {
264
+ return;
265
+ } else {
266
+ return recordAnswer(feed, generation, res._0);
267
+ }
268
+ }
269
+
270
+ function handlePushedHeight(feed, height) {
271
+ let advances = height > feed.knownHeight;
272
+ feed.recordRequestStats([{
273
+ method: advances ? "heightPush" : "heightPushIgnored",
274
+ seconds: 0
275
+ }]);
276
+ clearBackoff(feed);
277
+ let match = feed.stream;
278
+ if (typeof match !== "object") {
279
+ match === "NeverEnabled";
280
+ } else if (advances) {
281
+ feed.stream = {
282
+ TAG: "Connected",
283
+ proven: true
284
+ };
285
+ }
286
+ trustStreamAgain(feed);
287
+ recordHeight(feed, height);
288
+ wakeCadence(feed);
289
+ }
290
+
291
+ function markStreamDown(feed, reason) {
292
+ let match = feed.stream;
293
+ let hadConnection;
294
+ hadConnection = typeof match === "object";
295
+ feed.stream = "Disconnected";
296
+ if (hadConnection) {
297
+ Utils.Dict.incrementBy(feed.disconnects, Source.downReasonLabel(reason), 1);
298
+ trustStreamAgain(feed);
299
+ return syncPolling(feed);
300
+ }
301
+ }
302
+
303
+ function handleStatus(feed, status) {
304
+ if (typeof status !== "object") {
305
+ let match = feed.stream;
306
+ if (typeof match === "object") {
307
+ return;
308
+ }
309
+ match === "NeverEnabled";
310
+ feed.stream = {
311
+ TAG: "Connected",
312
+ proven: false
313
+ };
314
+ feed.generation = feed.generation + 1 | 0;
315
+ feed.connects = feed.connects + 1 | 0;
316
+ syncPolling(feed);
317
+ if (!feed.polling) {
318
+ catchUpWhileIdle(feed);
319
+ return;
320
+ } else {
321
+ return;
322
+ }
323
+ }
324
+ let reason = status.reason;
325
+ markStreamDown(feed, reason);
326
+ let log;
327
+ if (typeof reason !== "object") {
328
+ switch (reason) {
329
+ case "Unreadable" :
330
+ if (feed.unreadableWarned) {
331
+ log = Logging.childTrace;
332
+ } else {
333
+ feed.unreadableWarned = true;
334
+ log = Logging.childWarn;
335
+ }
336
+ break;
337
+ case "SubscribeRejected" :
338
+ if (!feed.subscribeRejectedWarned && feed.connects === 0) {
339
+ feed.subscribeRejectedWarned = true;
340
+ log = Logging.childWarn;
341
+ } else {
342
+ log = Logging.childTrace;
343
+ }
344
+ break;
345
+ default:
346
+ log = Logging.childTrace;
347
+ }
348
+ } else {
349
+ log = Logging.childTrace;
350
+ }
351
+ let label = Source.downReasonLabel(reason);
352
+ log(feed.logger, {
353
+ msg: `Height subscription for ` + feed.source.name + ` source went down (` + label + `). Polling for the height until it reconnects.`,
354
+ reason: label,
355
+ detail: status.detail
356
+ });
357
+ }
358
+
359
+ function enableStream(feed) {
360
+ let match = feed.source.createHeightSubscription;
361
+ let match$1 = feed.closeStream;
362
+ if (match === undefined) {
363
+ return;
364
+ }
365
+ if (match$1 !== undefined) {
366
+ return;
367
+ }
368
+ if (feed.stopped) {
369
+ return;
370
+ }
371
+ let match$2 = feed.stream;
372
+ if (typeof match$2 !== "object" && match$2 === "NeverEnabled") {
373
+ feed.stream = "Disconnected";
374
+ }
375
+ feed.closeStream = match(height => handlePushedHeight(feed, height), status => handleStatus(feed, status));
376
+ }
377
+
378
+ function stop(feed) {
379
+ feed.stopped = true;
380
+ let closeStream = feed.closeStream;
381
+ if (closeStream !== undefined) {
382
+ closeStream();
383
+ feed.closeStream = undefined;
384
+ return markStreamDown(feed, "Unsubscribed");
385
+ }
386
+ }
387
+
388
+ function onHeightAbove(feed, knownHeight, interval, onHeight) {
389
+ if (feed.knownHeight > knownHeight) {
390
+ onHeight(feed.knownHeight);
391
+ return {
392
+ unsubscribe: () => {},
393
+ distrustStream: () => {}
394
+ };
395
+ }
396
+ let waiter = {
397
+ knownHeight: knownHeight,
398
+ onHeight: onHeight,
399
+ interval: interval,
400
+ distrustsStream: false
401
+ };
402
+ feed.waiters.push(waiter);
403
+ syncPolling(feed);
404
+ return {
405
+ unsubscribe: () => {
406
+ feed.waiters = feed.waiters.filter(w => w !== waiter);
407
+ wakeCadence(feed);
408
+ },
409
+ distrustStream: () => {
410
+ let match = feed.stream;
411
+ if (typeof match !== "object" || waiter.distrustsStream) {
412
+ return;
413
+ } else {
414
+ waiter.distrustsStream = true;
415
+ return syncPolling(feed);
416
+ }
417
+ }
418
+ };
419
+ }
420
+
421
+ let pollTimeoutMillis = 60000;
422
+
423
+ export {
424
+ make,
425
+ knownHeight,
426
+ sample,
427
+ shouldPoll,
428
+ wakeCadence,
429
+ clearBackoff,
430
+ fireWaiters,
431
+ trustStreamAgain,
432
+ recordHeight,
433
+ recordAnswer,
434
+ recordLateAnswer,
435
+ pollTimeoutMillis,
436
+ heightWithin,
437
+ sleep,
438
+ currentInterval,
439
+ nextRetryInterval,
440
+ backOff,
441
+ pollOnce,
442
+ runPollLoop,
443
+ syncPolling,
444
+ catchUpWhileIdle,
445
+ handlePushedHeight,
446
+ markStreamDown,
447
+ handleStatus,
448
+ enableStream,
449
+ stop,
450
+ onHeightAbove,
451
+ }
452
+ /* Utils Not a pure module */
@@ -0,0 +1,257 @@
1
+ /*
2
+ Reconnect driver shared by every height subscription transport. A transport
3
+ supplies `connect`, which wires its socket to the driver callbacks and returns a
4
+ close function; the driver owns retries, staleness detection and status
5
+ reporting so SSE and WebSocket streams behave identically.
6
+
7
+ Nothing here logs. A height stream failing is not something an operator has to
8
+ act on — the indexer polls for the height instead — so a failure travels out
9
+ through `onStatus`, where its reason becomes a metric label and its detail the
10
+ one log line that says what the provider actually sent.
11
+ */
12
+
13
+ type driver = {
14
+ // The stream is usable: for SSE the connection opened, for WebSocket the
15
+ // subscription was confirmed.
16
+ onConnected: unit => unit,
17
+ onKeepAlive: unit => unit,
18
+ onHeight: int => unit,
19
+ // A message the transport couldn't read. Not a failure by itself, but from
20
+ // then on only a height we can read counts as the connection still being
21
+ // useful, so a stream of them goes quiet and fails as "unreadable", carrying
22
+ // the frame that started it.
23
+ onUnreadable: (~detail: string) => unit,
24
+ onFailure: (~reason: Source.heightSubscriptionDownReason, ~detail: string=?) => unit,
25
+ }
26
+
27
+ // While a stream is down its consumer polls instead, so the retry delay is what
28
+ // that fallback costs: a load balancer rotating a connection heals on the first
29
+ // retry, and every polling interval spent waiting for it is an avoidable
30
+ // request. Small first step, with the doubling to protect an endpoint that is
31
+ // genuinely in trouble.
32
+ let baseRetryMillis = 250
33
+ let maxRetryMillis = 60_000
34
+ // Floor for how long a connection has to serve to have been worth making, as a
35
+ // fraction of the window we would wait before calling it dead. Both transports
36
+ // deliver something the moment they connect — HyperSync sends the head, a
37
+ // WebSocket confirms the subscription — so an endpoint that accepts and drops
38
+ // produces everything a working one would have by then, and only staying up
39
+ // tells them apart. A fixed floor can't do it: one low enough to clear on a
40
+ // healthy connection is one a connection that dies seconds in clears too.
41
+ let provenStaleFraction = 4
42
+ // A frame nobody could read ends up in a log line, so cap what a provider can
43
+ // put there.
44
+ let maxDetailLength = 200
45
+
46
+ // The wait that follows `count` consecutive failures. Zero for a first
47
+ // connection, which nothing preceded. Deterministic: the bar a connection has
48
+ // to clear to count as proven is derived from it, and a jittered bar would make
49
+ // the rotated/closed split depend on a coin flip.
50
+ let retryDelayFor = count =>
51
+ count <= 0
52
+ ? 0
53
+ : Utils.expBackoff(~base=baseRetryMillis, ~exp=count - 1, ~maxMillis=maxRetryMillis)
54
+
55
+ let truncateDetail = detail =>
56
+ detail->String.length > maxDetailLength
57
+ ? detail->String.slice(~start=0, ~end=maxDetailLength) ++ "..."
58
+ : detail
59
+
60
+ let subscribe = (
61
+ ~staleTimeout: int,
62
+ ~onHeight: int => unit,
63
+ ~onStatus: Source.heightSubscriptionStatus => unit,
64
+ // Must not allocate anything it can then throw past: a throw leaves the driver
65
+ // without the close function, so only the transport can release what it took.
66
+ ~connect: driver => unit => unit,
67
+ ): (unit => unit) => {
68
+ let closeConnectionRef = ref(None)
69
+ let failureCount = ref(0)
70
+ // Bumped on every failure, reconnect and unsubscribe. Callbacks from a
71
+ // superseded connection compare against it and no-op, so a socket that
72
+ // reports an error and then a close only counts as one failure, and nothing a
73
+ // connection reports after unsubscribing is heard.
74
+ let generation = ref(0)
75
+ // A connection is either waiting for traffic or waiting to be retried, never
76
+ // both, so a single slot holds whichever timer is pending.
77
+ let timeoutId = ref(None)
78
+ // The frame that stopped this connection being readable, kept for the failure
79
+ // it will eventually be named after.
80
+ let unreadableDetail = ref(None)
81
+ // When the current connection reported itself live, if it ever did. Time
82
+ // before that is not service: a handshake that takes seconds to fail, or a
83
+ // socket that is accepted and then hangs until the staleness timer, would
84
+ // otherwise be indistinguishable from a connection that worked.
85
+ let liveAt = ref(None)
86
+ let minProvenMillis = staleTimeout / provenStaleFraction
87
+
88
+ let clearPendingTimeout = () => timeoutId->Utils.clearTimeoutRef
89
+
90
+ // A transport that throws while closing must not be able to stop the retry
91
+ // that follows, nor escape a timer callback and take the process down. Every
92
+ // close goes through here, including the one for a connection superseded
93
+ // while connect was still running.
94
+ let closeSafely = close =>
95
+ try close() catch {
96
+ | _ => ()
97
+ }
98
+
99
+ let closeConnection = () => {
100
+ switch closeConnectionRef.contents {
101
+ | Some(close) => closeSafely(close)
102
+ | None => ()
103
+ }
104
+ closeConnectionRef := None
105
+ }
106
+
107
+ let rec armStaleTimeout = () => {
108
+ clearPendingTimeout()
109
+ timeoutId := Some(setTimeout(() => {
110
+ timeoutId := None
111
+ // Distinguishes a provider whose message shape we don't understand
112
+ // from a chain that simply has nothing to report.
113
+ switch unreadableDetail.contents {
114
+ | Some(detail) => fail(~reason=Source.Unreadable, ~detail)
115
+ | None => fail(~reason=Source.Stale)
116
+ }
117
+ }, staleTimeout))
118
+ }
119
+ and fail = (~reason: Source.heightSubscriptionDownReason, ~detail=?) => {
120
+ generation := generation.contents + 1
121
+ clearPendingTimeout()
122
+ closeConnection()
123
+
124
+ // A connection that served for longer than the wait it cost was worth
125
+ // making, whatever ended it, so the backoff starts over. That covers a load
126
+ // balancer rotating connections and a chain whose blocks are further apart
127
+ // than the stale timeout alike, without either having to be recognised. The
128
+ // bar rises with the backoff, so an endpoint that always dies at the same
129
+ // age keeps escalating rather than settling into a loop at a fixed delay.
130
+ let servedMillis = switch liveAt.contents {
131
+ | Some(since) => (Performance.now() -. since)->Float.toInt
132
+ | None => 0
133
+ }
134
+ // Against the schedule, not against the jittered wait actually served. The
135
+ // spread exists to keep indexers from retrying in lockstep, and letting it
136
+ // reach this would put a coin flip in the rotated/closed split an operator
137
+ // reads. Judging a connection by the step of the ramp it was born on is
138
+ // conservative in the same direction every time.
139
+ let provenMillis = Pervasives.max(retryDelayFor(failureCount.contents), minProvenMillis)
140
+ failureCount := if servedMillis >= provenMillis {
141
+ 1
142
+ } else {
143
+ failureCount.contents + 1
144
+ }
145
+
146
+ let retryMillis = retryDelayFor(failureCount.contents)->Utils.jitter
147
+
148
+ // A clean end to a connection that had proven itself is a rotation: routine,
149
+ // and worth telling apart from the same clean end arriving early, which is a
150
+ // server dropping connections it should be serving. Neither transport can
151
+ // tell which it was, so the driver decides from how long the connection
152
+ // lasted — the same measure the backoff already trusts.
153
+ let reason: Source.heightSubscriptionDownReason = switch reason {
154
+ | Closed if servedMillis >= provenMillis => Rotated
155
+ | reason => reason
156
+ }
157
+
158
+ // Scheduled before reporting, so a consumer that throws can't be what makes
159
+ // the stream give up.
160
+ timeoutId := Some(setTimeout(() => {
161
+ timeoutId := None
162
+ start()
163
+ }, retryMillis))
164
+
165
+ onStatus(Down({reason, detail: ?(detail->Option.map(truncateDetail))}))
166
+ }
167
+ and start = () => {
168
+ generation := generation.contents + 1
169
+ unreadableDetail := None
170
+ liveAt := None
171
+ let connectionGeneration = generation.contents
172
+ let isCurrent = () => generation.contents === connectionGeneration
173
+
174
+ // Armed before connecting, so it covers a connect that never reports
175
+ // anything, and so a synchronous failure inside connect replaces it with
176
+ // the retry timeout rather than the other way around.
177
+ armStaleTimeout()
178
+
179
+ // A height is proof the stream is delivering, so it reports Live as well as
180
+ // onConnected does. Without that, a transport that never reports the
181
+ // connection usable would leave its consumer polling at full rate next to a
182
+ // stream that works.
183
+ let goLive = () => {
184
+ armStaleTimeout()
185
+ if liveAt.contents->Option.isNone {
186
+ liveAt := Some(Performance.now())
187
+ onStatus(Live)
188
+ }
189
+ }
190
+
191
+ let driver = {
192
+ onConnected: () =>
193
+ if isCurrent() {
194
+ goLive()
195
+ },
196
+ onKeepAlive: () =>
197
+ // Stops counting once anything unreadable has arrived, on the first one
198
+ // rather than on some run of them: a stream whose heights are all
199
+ // malformed is indistinguishable from one that has sent a single
200
+ // garbled frame until a readable height arrives, and the keep-alives
201
+ // between malformed heights would otherwise hold the connection open
202
+ // indefinitely, never failing and so never reporting anything, while
203
+ // its consumer sat on the staleness backstop. Being wrong about a lone
204
+ // glitch costs one reconnect.
205
+ if isCurrent() && unreadableDetail.contents->Option.isNone {
206
+ armStaleTimeout()
207
+ },
208
+ onHeight: height =>
209
+ if isCurrent() {
210
+ // A height we could read proves the shape is fine again. A keep-alive
211
+ // deliberately doesn't: on a stream whose heights are all malformed,
212
+ // the pings between them would clear this and the staleness that
213
+ // followed would look like a chain with nothing to report.
214
+ unreadableDetail := None
215
+ goLive()
216
+ onHeight(height)
217
+ },
218
+ onUnreadable: (~detail) =>
219
+ if isCurrent() {
220
+ unreadableDetail := Some(detail)
221
+ },
222
+ onFailure: (~reason, ~detail=?) =>
223
+ if isCurrent() {
224
+ fail(~reason, ~detail?)
225
+ },
226
+ }
227
+
228
+ // A transport constructor can throw on a malformed url. Left to escape it
229
+ // would reach a timer callback on the next retry and take the process down.
230
+ // What it says is the only account of why the url can't be connected to, and
231
+ // this retries forever, so it goes out as the failure's detail.
232
+ let connected = try Ok(connect(driver)) catch {
233
+ | exn => Error(exn)
234
+ }
235
+
236
+ switch connected {
237
+ | Ok(closeCurrentConnection) =>
238
+ if isCurrent() {
239
+ closeConnectionRef := Some(closeCurrentConnection)
240
+ } else {
241
+ closeSafely(closeCurrentConnection)
242
+ }
243
+ | Error(exn) =>
244
+ if isCurrent() {
245
+ fail(~reason=ConnectFailed, ~detail=?exn->Utils.exnMessage)
246
+ }
247
+ }
248
+ }
249
+
250
+ start()
251
+
252
+ () => {
253
+ generation := generation.contents + 1
254
+ clearPendingTimeout()
255
+ closeConnection()
256
+ }
257
+ }