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,568 @@
1
+ /*
2
+ The current height of one source, however it arrives: pushed by a height stream
3
+ while one is connected, polled while one isn't. Callers register interest and get
4
+ called back; they never learn which path answered.
5
+
6
+ One invariant carries the module: while anybody is waiting, either a stream
7
+ connection this has seen deliver is covering the source, or the poll loop is
8
+ running. `shouldPoll` states it, and `syncPolling` is the only thing that acts on
9
+ it, so no caller has to work out which of its changes needs a loop started.
10
+
11
+ A waiter that loses a race has to be removed, and a promise reaction cannot be.
12
+ */
13
+
14
+ type waiter = {
15
+ // The height this waiter has to beat.
16
+ knownHeight: int,
17
+ onHeight: int => unit,
18
+ // How fast to poll while nothing is pushing. Read per poll rather than fixed
19
+ // here, so a wait that goes on to stall slows itself down without restarting
20
+ // anything.
21
+ interval: unit => int,
22
+ // The wait this waiter belongs to sat out a whole window hearing nothing from
23
+ // a stream that says it is connected. It belongs to the waiter rather than the
24
+ // feed because two waits can overlap on one source after a rollback, and one
25
+ // of them giving up on the stream is not the other one doing so.
26
+ mutable distrustsStream: bool,
27
+ }
28
+
29
+ // What a waiter's own wait can do to it afterwards. Both are inert once the
30
+ // waiter is gone, which is what makes them safe to hold past the answer.
31
+ type subscription = {
32
+ unsubscribe: unit => unit,
33
+ // Stop taking a connected stream's word for it and poll alongside it until it
34
+ // delivers something. A transport that keeps its keep-alives flowing while its
35
+ // heights stop is the one failure its own staleness detector cannot see.
36
+ distrustStream: unit => unit,
37
+ }
38
+
39
+ // What this feed knows about its source's height stream. `Connected` is the
40
+ // transport's claim; `proven` is this module having seen that connection account
41
+ // for the head it came up on. A connect only ever delivers the *next* block, so
42
+ // until a height arrives on it, or a request made after it came up answers, the
43
+ // head it connected above is unaccounted for and polling has to stay.
44
+ type streamState =
45
+ // Never asked for — the source cannot push heights, or its chain has not
46
+ // reached realtime yet. Nothing about it is worth reporting, because nothing
47
+ // about it is failing.
48
+ | NeverEnabled
49
+ | Disconnected
50
+ | Connected({proven: bool})
51
+
52
+ // Why the poll loop is sleeping, which is what decides whether anything may cut
53
+ // the sleep short. A cadence is a choice about how often to ask a working
54
+ // endpoint, so anything that needs an answer sooner is free to end it. A backoff
55
+ // is the wait a failing endpoint earned, and ending it early is how a provider
56
+ // already in trouble gets asked harder by the very events its trouble causes —
57
+ // its own stream dropping, most of all.
58
+ type pollSleep = Cadence | Backoff
59
+
60
+ // Mutable because a sleep can stop being a backoff while it is being slept: the
61
+ // endpoint answering is what the wait was ever about, and it can answer through
62
+ // the stream rather than through the poll the loop is backing off from.
63
+ type sleeping = {mutable kind: pollSleep, wake: unit => unit}
64
+
65
+ type pollOutcome =
66
+ | Answered(Source.getHeightResponse)
67
+ | Failed(exn)
68
+ // The source did not answer inside the window. One loop covers this feed and
69
+ // it waits on one request at a time, so a call that never settles would stop
70
+ // the height moving for the rest of the process rather than for one poll.
71
+ | TimedOut
72
+
73
+ type t = {
74
+ source: Source.t,
75
+ logger: Pino.t,
76
+ recordRequestStats: array<Source.requestStat> => unit,
77
+ getHeightRetryInterval: (~retry: int) => int,
78
+ mutable knownHeight: int,
79
+ mutable waiters: array<waiter>,
80
+ mutable stream: streamState,
81
+ // Bumped for every connection this feed sees come up. A height request made
82
+ // for one connection can land after another has replaced it, and what it said
83
+ // about the connection that asked is no longer about the one in place.
84
+ mutable generation: int,
85
+ mutable closeStream: option<unit => unit>,
86
+ mutable polling: bool,
87
+ mutable sleeping: option<sleeping>,
88
+ // Outlives a single wait on purpose. Waits come and go once a block, and
89
+ // restarting the ramp with each one would keep an endpoint that fails every
90
+ // time pinned near the base delay forever. A poll that answers clears it.
91
+ mutable pollRetry: int,
92
+ mutable connects: int,
93
+ disconnects: dict<int>,
94
+ // A stopped feed never subscribes again: `stop` is the capability verdict that
95
+ // benched its source.
96
+ mutable stopped: bool,
97
+ // Whether an operator has been told once about a stream condition that never
98
+ // heals on its own. Each of them repeats for as long as its cause is there.
99
+ mutable unreadableWarned: bool,
100
+ mutable subscribeRejectedWarned: bool,
101
+ }
102
+
103
+ let make = (~source: Source.t, ~recordRequestStats, ~getHeightRetryInterval): t => {
104
+ source,
105
+ logger: Logging.createChild(~params={"chainId": source.chainId, "source": source.name}),
106
+ recordRequestStats,
107
+ getHeightRetryInterval,
108
+ knownHeight: 0,
109
+ waiters: [],
110
+ stream: NeverEnabled,
111
+ generation: 0,
112
+ closeStream: None,
113
+ polling: false,
114
+ sleeping: None,
115
+ pollRetry: 0,
116
+ connects: 0,
117
+ disconnects: Dict.make(),
118
+ stopped: false,
119
+ unreadableWarned: false,
120
+ subscribeRejectedWarned: false,
121
+ }
122
+
123
+ type streamSample = {connectCount: int, disconnectsByReason: array<(string, int)>}
124
+
125
+ let knownHeight = (feed: t) => feed.knownHeight
126
+
127
+ // None until this feed has asked for a stream, so a chain that only ever polls —
128
+ // because its source cannot subscribe, or because it is still backfilling and
129
+ // nothing has wanted a stream yet — renders none of the height stream families
130
+ // rather than sitting at a flat zero on them, which reads as a stream that
131
+ // cannot connect. Once asked for, a stream that never came up is exactly what
132
+ // these counters are for.
133
+ let sample = (feed: t): option<streamSample> =>
134
+ switch feed.stream {
135
+ | NeverEnabled => None
136
+ | Disconnected | Connected(_) =>
137
+ Some({
138
+ connectCount: feed.connects,
139
+ // Sorted so the rendered order doesn't depend on which reasons a stream
140
+ // happened to hit in which order.
141
+ disconnectsByReason: feed.disconnects
142
+ ->Dict.toArray
143
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b)),
144
+ })
145
+ }
146
+
147
+ // What this feed owes, in one place. Everything else here exists to keep it true.
148
+ let shouldPoll = (feed: t) =>
149
+ feed.waiters->Array.length > 0 &&
150
+ switch feed.stream {
151
+ | NeverEnabled | Disconnected => true
152
+ | Connected({proven}) => !proven || feed.waiters->Array.some(waiter => waiter.distrustsStream)
153
+ }
154
+
155
+ // Ends a sleep that is only a cadence, and leaves a backoff alone. Safe to call
156
+ // from anywhere, including from things that have just made the loop's job
157
+ // smaller: the loop re-reads `shouldPoll` when it wakes and stops if there is
158
+ // nothing left to do.
159
+ let wakeCadence = (feed: t) =>
160
+ switch feed.sleeping {
161
+ | Some({kind: Cadence, wake}) => wake()
162
+ | Some({kind: Backoff}) | None => ()
163
+ }
164
+
165
+ // The source answered, so the wait its failures earned is no longer owed —
166
+ // including one already being slept. Without this a source that recovers through
167
+ // its stream, and then loses the stream again, would sit out the rest of a
168
+ // backoff earned before any of that, with nothing covering it.
169
+ let clearBackoff = (feed: t) => {
170
+ feed.pollRetry = 0
171
+ switch feed.sleeping {
172
+ | Some(sleeping) => sleeping.kind = Cadence
173
+ | None => ()
174
+ }
175
+ }
176
+
177
+ // Collect before firing: a callback is free to cancel waiters — the wait above
178
+ // cancels all of its own the moment one answers — and iterating the live array
179
+ // while it changes underneath is how that goes wrong.
180
+ let fireWaiters = (feed: t, height) => {
181
+ let isSatisfied = (waiter: waiter) => height > waiter.knownHeight
182
+ let satisfied = feed.waiters->Array.filter(isSatisfied)
183
+ if satisfied->Array.length > 0 {
184
+ feed.waiters = feed.waiters->Array.filter(waiter => !isSatisfied(waiter))
185
+ satisfied->Array.forEach(waiter =>
186
+ try waiter.onHeight(height) catch {
187
+ | exn =>
188
+ feed.logger->Logging.childError({
189
+ "msg": "A height waiter threw. Dropping it rather than losing the height for the others.",
190
+ "err": exn->Utils.prettifyExn,
191
+ })
192
+ }
193
+ )
194
+ // Answering the last waiter is one of the ways the loop stops being needed,
195
+ // and the callbacks just run may have cancelled others.
196
+ feed->wakeCadence
197
+ }
198
+ }
199
+
200
+ // A verdict a wait reached about a connection says nothing about the next one,
201
+ // nor about a stream that has just delivered.
202
+ let trustStreamAgain = (feed: t) =>
203
+ feed.waiters->Array.forEach(waiter => waiter.distrustsStream = false)
204
+
205
+ let recordHeight = (feed: t, height) =>
206
+ if height > feed.knownHeight {
207
+ feed.knownHeight = height
208
+ feed->fireWaiters(height)
209
+ }
210
+
211
+ // What an answer means for the feed, whoever asked for it. The poll loop and a
212
+ // connect's catch-up both reach this, and an answer is worth the same from
213
+ // either: the endpoint responded, and it responded with the head.
214
+ let recordAnswer = (feed: t, ~generation, res: Source.getHeightResponse) => {
215
+ feed.recordRequestStats(res.requestStats)
216
+ feed->clearBackoff
217
+ // Fetching the head is the whole job a connect's catch-up exists to do, so any
218
+ // answer closes that gap too — as long as it was asked for after the
219
+ // connection in place came up. A request older than the connection saw a head
220
+ // from before it existed, and a catch-up that lands after its own connection
221
+ // was replaced would otherwise retire the polling covering a replacement that
222
+ // has delivered nothing.
223
+ switch feed.stream {
224
+ | Connected(_) if generation === feed.generation => feed.stream = Connected({proven: true})
225
+ | _ => ()
226
+ }
227
+ feed->recordHeight(res.height)
228
+ // Answering may have been the last of the loop's reasons to run. Costs nothing
229
+ // when the loop itself is the caller: it only sleeps after this returns.
230
+ feed->wakeCadence
231
+ }
232
+
233
+ // An answer that arrived after the loop stopped waiting for it. The height is
234
+ // real and the request was paid for either way, so recording it saves asking
235
+ // again for something already known — but it does not clear the retry ramp the
236
+ // way a timely answer does, because taking longer than the whole timeout is the
237
+ // endpoint being unwell rather than well.
238
+ let recordLateAnswer = (feed: t, res: Source.getHeightResponse) => {
239
+ feed.recordRequestStats(res.requestStats)
240
+ feed->recordHeight(res.height)
241
+ }
242
+
243
+ // A source that has not answered in this long is not going to. Far longer than
244
+ // any healthy getHeight, so a merely slow endpoint answers first and nothing
245
+ // here fires in normal running.
246
+ let pollTimeoutMillis = 60_000
247
+
248
+ // One height request, with a bound on how long the caller waits for it. The
249
+ // request itself is not cancelled and a late answer is not thrown away: it is
250
+ // still the head the endpoint had. Only the waiting is bounded, so an endpoint
251
+ // that never settles costs one request rather than the loop waiting on it — or,
252
+ // on the catch-up path that nothing waits on, a promise per connect that is
253
+ // never released.
254
+ let heightWithin = (feed: t): promise<pollOutcome> => {
255
+ let timeoutId = ref(None)
256
+ let timedOut = ref(false)
257
+ let request = feed.source.getHeightOrThrow()
258
+
259
+ request
260
+ ->Promise.thenResolve(res =>
261
+ if timedOut.contents {
262
+ feed->recordLateAnswer(res)
263
+ }
264
+ )
265
+ ->Promise.catch(_ => Promise.resolve())
266
+ ->Promise.ignore
267
+
268
+ Promise.race([
269
+ request
270
+ ->Promise.thenResolve(res => Answered(res))
271
+ ->Promise.catch(exn => Promise.resolve(Failed(exn))),
272
+ Promise.make((resolve, _reject) => timeoutId := Some(setTimeout(() => {
273
+ timedOut := true
274
+ resolve(TimedOut)
275
+ }, pollTimeoutMillis))),
276
+ ])->Promise.thenResolve(outcome => {
277
+ timeoutId->Utils.clearTimeoutRef
278
+ outcome
279
+ })
280
+ }
281
+
282
+ let sleep = (feed: t, ~kind, millis) =>
283
+ Promise.make((resolve, _reject) => {
284
+ let timeoutId = setTimeout(() => {
285
+ feed.sleeping = None
286
+ resolve()
287
+ }, millis)
288
+ feed.sleeping = Some({
289
+ kind,
290
+ wake: () => {
291
+ clearTimeout(timeoutId)
292
+ feed.sleeping = None
293
+ resolve()
294
+ },
295
+ })
296
+ })
297
+
298
+ // The shortest cadence any waiter asked for. Waits overlap on one source after a
299
+ // rollback, and a wait that only needs a slow poll must not stretch the one a
300
+ // faster wait is sitting on.
301
+ let currentInterval = (feed: t) =>
302
+ feed.waiters
303
+ ->Array.reduce(None, (shortest, waiter) =>
304
+ Utils.Math.minOptInt(shortest, Some(waiter.interval()))
305
+ )
306
+ ->Option.getOr(feed.source.pollingInterval)
307
+
308
+ let nextRetryInterval = (feed: t) => {
309
+ let retryInterval = feed.getHeightRetryInterval(~retry=feed.pollRetry)
310
+ feed.pollRetry = feed.pollRetry + 1
311
+ retryInterval
312
+ }
313
+
314
+ // The wait a poll that did not answer earns, and the line that says why it was
315
+ // earned. Both ways a poll can fail come through here, so the ramp and what is
316
+ // said about it stay one thing.
317
+ let backOff = (feed: t, ~what, ~exn=?) => {
318
+ let retryInterval = feed->nextRetryInterval
319
+ feed.logger->Logging.childTrace({
320
+ "msg": `Height retrieval from ${feed.source.name} source ${what}. Retrying in ${retryInterval->Int.toString}ms.`,
321
+ "err": exn->Option.map(Utils.prettifyExn),
322
+ })
323
+ (retryInterval, Backoff)
324
+ }
325
+
326
+ // One poll, and what the loop should wait before the next: the source's own
327
+ // cadence after an answer, or the escalating backoff a failing endpoint earns.
328
+ // It is usually the same endpoint whose stream just dropped, so asking again at
329
+ // the polling interval would lean on something already in trouble.
330
+ let pollOnce = async (feed: t) => {
331
+ let generation = feed.generation
332
+ switch await feed->heightWithin {
333
+ | Answered(res) =>
334
+ feed->recordAnswer(~generation, res)
335
+ (feed->currentInterval, Cadence)
336
+ | TimedOut =>
337
+ feed->backOff(~what=`did not answer within ${(pollTimeoutMillis / 1000)->Int.toString}s`)
338
+ | Failed(exn) => feed->backOff(~what="failed", ~exn)
339
+ }
340
+ }
341
+
342
+ let runPollLoop = async (feed: t) => {
343
+ while feed->shouldPoll {
344
+ // A poll cannot end the loop: its failure is a value, and a waiter's is
345
+ // caught where it fires. Anything else in here throwing would leave coverage
346
+ // owed with nothing providing it — the one state this module must never be
347
+ // in — so the loop keeps that promise itself rather than trusting everything
348
+ // it calls to be total.
349
+ let (interval, kind) = try await feed->pollOnce catch {
350
+ | exn =>
351
+ feed.logger->Logging.childError({
352
+ "msg": "The height poll cycle threw. Backing off and carrying on rather than leaving the source uncovered.",
353
+ "err": exn->Utils.prettifyExn,
354
+ })
355
+ (feed->nextRetryInterval, Backoff)
356
+ }
357
+ if feed->shouldPoll {
358
+ await feed->sleep(~kind, interval)
359
+ }
360
+ }
361
+ feed.polling = false
362
+ }
363
+
364
+ // The only thing that acts on `shouldPoll`. Everything that changes what this
365
+ // feed owes ends here, so no caller has to know which changes need a loop
366
+ // started, which need a sleep chosen before them cut short, and which need
367
+ // neither.
368
+ let syncPolling = (feed: t) =>
369
+ if feed->shouldPoll && !feed.polling {
370
+ feed.polling = true
371
+ feed->runPollLoop->Promise.ignore
372
+ } else {
373
+ // Either there is now less to do and the loop should notice, or there is
374
+ // more and it should stop sleeping through it.
375
+ feed->wakeCadence
376
+ }
377
+
378
+ // The one height request this module makes with nobody waiting for it, closing
379
+ // the gap left by heights emitted before this connection existed. A chain that
380
+ // reconnects while idle would otherwise sit on a stale head until the next block
381
+ // is mined. With a waiter there is a loop doing this already, and asking twice
382
+ // does not make the endpoint answer sooner.
383
+ let catchUpWhileIdle = async (feed: t) => {
384
+ let generation = feed.generation
385
+ switch await feed->heightWithin {
386
+ | Answered(res) => feed->recordAnswer(~generation, res)
387
+ // Nobody is waiting, so nothing is owed and there is nothing to retry: the
388
+ // next wait polls on its own, and the next height the stream pushes lands
389
+ // whatever happened here.
390
+ | Failed(_) | TimedOut => ()
391
+ }
392
+ }
393
+
394
+ let handlePushedHeight = (feed: t, height) => {
395
+ let advances = height > feed.knownHeight
396
+ feed.recordRequestStats([
397
+ {Source.method: advances ? "heightPush" : "heightPushIgnored", seconds: 0.},
398
+ ])
399
+
400
+ // A height that advances accounts for the gap a connect leaves. One that does
401
+ // not is the head a stream re-emits on reconnect — but either way it is the
402
+ // stream delivering, which is all the silence behind a distrusting wait ever
403
+ // claimed otherwise — and the source answering at all, which is what any
404
+ // backoff beside it was waiting to find out.
405
+ feed->clearBackoff
406
+ switch feed.stream {
407
+ | Connected(_) if advances => feed.stream = Connected({proven: true})
408
+ | _ => ()
409
+ }
410
+ feed->trustStreamAgain
411
+ feed->recordHeight(height)
412
+ // Never more to do than before: a delivering stream only takes work away.
413
+ feed->wakeCadence
414
+ }
415
+
416
+ // Everything a connection going away means for the feed. The stream reporting it
417
+ // and this module closing it leave the same state behind, so they share it:
418
+ // nothing is pushing, nothing is proven, and what the waiters concluded about
419
+ // the connection that is gone says nothing about the one that replaces it.
420
+ let markStreamDown = (feed: t, ~reason) => {
421
+ let hadConnection = switch feed.stream {
422
+ | Connected(_) => true
423
+ | NeverEnabled | Disconnected => false
424
+ }
425
+ feed.stream = Disconnected
426
+
427
+ // Only a connection that existed can be lost. A stream that is down stays down
428
+ // through every failed retry, and each of those reports Down again: counting
429
+ // them would make the total measure how long an outage lasted rather than how
430
+ // many there were, and would leave a stream that has never connected
431
+ // disconnecting without ever having connected. Nothing else here has anything
432
+ // to do either — no waiter can be distrusting a stream that was already down,
433
+ // and what this feed owes has not changed — and cutting the poll cadence short
434
+ // for each of them would ask the endpoint again on the schedule of its own
435
+ // stream's retries.
436
+ if hadConnection {
437
+ feed.disconnects->Utils.Dict.incrementBy(reason->Source.downReasonLabel, 1)
438
+ // The connection they gave up on is gone; the one that replaces it starts
439
+ // with a head of its own to account for.
440
+ feed->trustStreamAgain
441
+ feed->syncPolling
442
+ }
443
+ }
444
+
445
+ let handleStatus = (feed: t, status: Source.heightSubscriptionStatus) =>
446
+ switch status {
447
+ | Live =>
448
+ switch feed.stream {
449
+ // A transport re-reporting a connection this already has.
450
+ | Connected(_) => ()
451
+ | NeverEnabled | Disconnected =>
452
+ feed.stream = Connected({proven: false})
453
+ feed.generation = feed.generation + 1
454
+ feed.connects = feed.connects + 1
455
+
456
+ // Live is a claim, not a delivery: polling keeps covering the source until
457
+ // the head this connection came up above is accounted for. A loop already
458
+ // running is the thing that will account for it, and one sleeping off a
459
+ // backoff stays asleep — the endpoint that just accepted a socket is the
460
+ // one whose height calls are failing.
461
+ feed->syncPolling
462
+ if !feed.polling {
463
+ feed->catchUpWhileIdle->Promise.ignore
464
+ }
465
+ }
466
+ | Down({reason} as down) =>
467
+ feed->markStreamDown(~reason)
468
+
469
+ // The counters say a stream is flapping and how often, but only the
470
+ // provider's own words say why, and a frame nobody could read is
471
+ // unrecoverable from a bucketed label. An outage is the indexer's to absorb
472
+ // — it polls instead — so these go out at trace, except for the two that
473
+ // never heal on their own: silently polling forever against a stream that
474
+ // could be working is worth one line an operator can see. Once each: both
475
+ // conditions repeat on every retry for as long as their cause is there.
476
+ let log = switch reason {
477
+ | Unreadable if !feed.unreadableWarned =>
478
+ feed.unreadableWarned = true
479
+ Logging.childWarn
480
+ // Refused without this feed ever having had a connection accepted, which
481
+ // makes it the endpoint's answer about itself rather than a bad moment: the
482
+ // url cannot serve heights, and no amount of retrying will change that.
483
+ | SubscribeRejected if !feed.subscribeRejectedWarned && feed.connects === 0 =>
484
+ feed.subscribeRejectedWarned = true
485
+ Logging.childWarn
486
+ | _ => Logging.childTrace
487
+ }
488
+ let label = reason->Source.downReasonLabel
489
+ feed.logger->log({
490
+ "msg": `Height subscription for ${feed.source.name} source went down (${label}). Polling for the height until it reconnects.`,
491
+ "reason": label,
492
+ "detail": down.detail,
493
+ })
494
+ }
495
+
496
+ // Explicit and lazy: the caller subscribes when it starts wanting heights in
497
+ // realtime, not when the feed is built. Idempotent, because after a rollback two
498
+ // waits run for the same source and both reach this — a second subscription
499
+ // would overwrite the first one's close function, leaving a socket nothing can
500
+ // close, still pushing heights and still retrying, for the life of the process.
501
+ let enableStream = (feed: t) =>
502
+ switch (feed.source.createHeightSubscription, feed.closeStream) {
503
+ | (Some(createSubscription), None) if !feed.stopped =>
504
+ // Before connecting, so a transport that fails inside `createSubscription`
505
+ // reports against a feed that already has a stream to report on.
506
+ switch feed.stream {
507
+ | NeverEnabled => feed.stream = Disconnected
508
+ | Disconnected | Connected(_) => ()
509
+ }
510
+ feed.closeStream = Some(
511
+ createSubscription(
512
+ ~onHeight=height => feed->handlePushedHeight(height),
513
+ ~onStatus=status => feed->handleStatus(status),
514
+ ),
515
+ )
516
+ | _ => ()
517
+ }
518
+
519
+ let stop = (feed: t) => {
520
+ feed.stopped = true
521
+ switch feed.closeStream {
522
+ | Some(closeStream) =>
523
+ closeStream()
524
+ feed.closeStream = None
525
+
526
+ // Counted as a disconnect like any other: leaving it out would leave the
527
+ // source reporting one more connect than disconnects — a stream still
528
+ // delivering — for the rest of the process. Being benched is a capability
529
+ // verdict, not an outage, so whoever is still waiting keeps being polled
530
+ // for: the source can still answer a height poll, and until another source
531
+ // answers the wait it is what there is.
532
+ feed->markStreamDown(~reason=Unsubscribed)
533
+ | None => ()
534
+ }
535
+ }
536
+
537
+ // Fires once, at the first height above `knownHeight`, from a push, a poll or a
538
+ // height another part of the indexer observed. What comes back acts on this
539
+ // waiter alone, and does nothing once it is gone.
540
+ let onHeightAbove = (feed: t, ~knownHeight, ~interval, ~onHeight): subscription =>
541
+ if feed.knownHeight > knownHeight {
542
+ // Already past it: a wait that starts after a query moved the head has
543
+ // nothing to wait for.
544
+ onHeight(feed.knownHeight)
545
+ {unsubscribe: () => (), distrustStream: () => ()}
546
+ } else {
547
+ let waiter = {knownHeight, onHeight, interval, distrustsStream: false}
548
+ feed.waiters->Array.push(waiter)->ignore
549
+ feed->syncPolling
550
+ {
551
+ unsubscribe: () => {
552
+ // Removing by reference is what makes this idempotent, including from
553
+ // inside onHeight where the waiter has already been taken out.
554
+ feed.waiters = feed.waiters->Array.filter(w => w !== waiter)
555
+ // The loop's reason to run may have just left with it.
556
+ feed->wakeCadence
557
+ },
558
+ distrustStream: () =>
559
+ switch feed.stream {
560
+ | Connected(_) if !waiter.distrustsStream =>
561
+ waiter.distrustsStream = true
562
+ feed->syncPolling
563
+ // Nothing to distrust, or already distrusted. A waiter that has been
564
+ // answered is no longer in the list, so this does nothing for it either.
565
+ | _ => ()
566
+ },
567
+ }
568
+ }