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.
- package/evm.schema.json +26 -0
- package/fuel.schema.json +26 -0
- package/index.d.ts +35 -3
- package/package.json +6 -7
- package/src/Batch.res +9 -0
- package/src/ChainState.res +35 -39
- package/src/ChainState.res.mjs +14 -11
- package/src/ChainState.resi +15 -7
- package/src/Config.res +52 -44
- package/src/Config.res.mjs +51 -17
- package/src/Core.res +4 -0
- package/src/CrossChainState.res +15 -23
- package/src/CrossChainState.res.mjs +10 -18
- package/src/CrossChainState.resi +4 -1
- package/src/Ecosystem.res +7 -2
- package/src/Envio.res +6 -4
- package/src/EventConfigBuilder.res +169 -26
- package/src/EventConfigBuilder.res.mjs +124 -15
- package/src/HandlerRegister.res +21 -4
- package/src/HandlerRegister.res.mjs +24 -5
- package/src/InMemoryStore.res +9 -7
- package/src/InMemoryStore.res.mjs +3 -3
- package/src/IndexerState.res +43 -6
- package/src/IndexerState.res.mjs +36 -7
- package/src/IndexerState.resi +4 -4
- package/src/Internal.res +16 -17
- package/src/Metrics.res +72 -6
- package/src/Metrics.res.mjs +30 -2
- package/src/Persistence.res +36 -30
- package/src/Persistence.res.mjs +1 -20
- package/src/PgStorage.res +104 -56
- package/src/PgStorage.res.mjs +57 -35
- package/src/PruneStaleHistory.res +85 -52
- package/src/PruneStaleHistory.res.mjs +63 -37
- package/src/Rollback.res +68 -45
- package/src/Rollback.res.mjs +38 -19
- package/src/RollbackCommit.res +7 -9
- package/src/RollbackCommit.res.mjs +5 -6
- package/src/SafeCheckpointTracking.res +11 -11
- package/src/SafeCheckpointTracking.res.mjs +7 -6
- package/src/SimulateItems.res +33 -18
- package/src/SimulateItems.res.mjs +26 -22
- package/src/Sink.res +38 -26
- package/src/Sink.res.mjs +21 -16
- package/src/TestIndexer.res +5 -5
- package/src/TestIndexer.res.mjs +1 -1
- package/src/Utils.res +170 -0
- package/src/Utils.res.mjs +153 -0
- package/src/Writing.res +2 -2
- package/src/Writing.res.mjs +1 -1
- package/src/bindings/ClickHouse.res +366 -698
- package/src/bindings/ClickHouse.res.mjs +371 -421
- package/src/bindings/ClickHouseSink.res +337 -0
- package/src/bindings/ClickHouseSink.res.mjs +246 -0
- package/src/bindings/EventSource.res +8 -2
- package/src/bindings/NodeJs.res +6 -0
- package/src/bindings/Vitest.res +23 -0
- package/src/bindings/Vitest.res.mjs +3 -0
- package/src/bindings/WebSocket.res +9 -10
- package/src/db/CheckpointBounds.res +53 -0
- package/src/db/CheckpointBounds.res.mjs +44 -0
- package/src/db/EntityFilter.res +36 -17
- package/src/db/EntityFilter.res.mjs +25 -14
- package/src/db/EntityHistory.res +47 -22
- package/src/db/EntityHistory.res.mjs +19 -12
- package/src/db/InternalTable.res +84 -35
- package/src/db/InternalTable.res.mjs +56 -23
- package/src/db/RollbackFloors.res +55 -0
- package/src/db/RollbackFloors.res.mjs +92 -0
- package/src/db/Table.res +22 -4
- package/src/db/Table.res.mjs +30 -9
- package/src/sources/Evm.res +2 -0
- package/src/sources/Evm.res.mjs +2 -0
- package/src/sources/EvmHyperSyncSource.res +3 -3
- package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
- package/src/sources/EvmRpcWs.res +124 -0
- package/src/sources/EvmRpcWs.res.mjs +117 -0
- package/src/sources/Fuel.res +2 -0
- package/src/sources/Fuel.res.mjs +2 -0
- package/src/sources/FuelHyperSyncSource.res +1 -1
- package/src/sources/FuelHyperSyncSource.res.mjs +2 -1
- package/src/sources/HeightFeed.res +568 -0
- package/src/sources/HeightFeed.res.mjs +452 -0
- package/src/sources/HeightStream.res +257 -0
- package/src/sources/HeightStream.res.mjs +194 -0
- package/src/sources/HyperSync.res +5 -0
- package/src/sources/HyperSync.res.mjs +3 -0
- package/src/sources/HyperSync.resi +4 -0
- package/src/sources/HyperSyncSSE.res +49 -0
- package/src/sources/HyperSyncSSE.res.mjs +79 -0
- package/src/sources/RpcSource.res +1 -1
- package/src/sources/RpcSource.res.mjs +2 -2
- package/src/sources/Source.res +54 -1
- package/src/sources/Source.res.mjs +25 -0
- package/src/sources/SourceManager.res +220 -227
- package/src/sources/SourceManager.res.mjs +139 -164
- package/src/sources/SourceManager.resi +8 -0
- package/src/sources/Svm.res +2 -0
- package/src/sources/Svm.res.mjs +2 -0
- package/src/sources/SvmHyperSyncClient.res +70 -127
- package/src/sources/SvmHyperSyncClient.res.mjs +51 -25
- package/src/sources/SvmHyperSyncSource.res +18 -15
- package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
- package/svm.schema.json +1 -75
- package/src/MemoryStorage.res +0 -729
- package/src/MemoryStorage.res.mjs +0 -586
- package/src/sources/HyperSyncHeightStream.res +0 -129
- package/src/sources/HyperSyncHeightStream.res.mjs +0 -110
- package/src/sources/RpcWebSocketHeightStream.res +0 -175
- 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
|
+
}
|