trac-msb 0.2.24 → 0.2.27
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/package.json +5 -2
- package/rpc/handlers.js +9 -0
- package/rpc/routes/v1.js +2 -0
- package/scripts/hypercore-stallfix/apply.mjs +199 -0
- package/scripts/hypercore-stallfix/hypercore-11.18.3.mjs +434 -0
- package/scripts/hypercore-stallfix/hypercore-11.8.3.mjs +348 -0
- package/src/config/config.js +10 -0
- package/src/config/env.js +6 -0
- package/src/core/network/services/ReplicationStallGuard.js +177 -0
- package/src/core/state/ConfirmationWatch.js +82 -0
- package/src/core/state/State.js +5 -0
- package/src/index.js +28 -0
- package/src/utils/hypercoreStallfix.js +53 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Exact-text patch set for hypercore 11.8.3 lib/replicator.js (OPENMAYHEM-STALLFIX v2).
|
|
2
|
+
// Same logic as hypercore-11.18.3.mjs, written against the 11.8.3 source (older code style, NOT_AVAILABLE has no
|
|
3
|
+
// reason field on the wire in 11.8.3, INVALID_REQUEST does not exist yet).
|
|
4
|
+
// Each edit is [original, replacement]; the original must occur exactly once in the unpatched file.
|
|
5
|
+
// Keep this file free of backticks and dollar-brace sequences inside the edit texts (String.raw templates).
|
|
6
|
+
|
|
7
|
+
// Upstream c24f37a6 (#911): a close event that already fired cannot detach a later attachment.
|
|
8
|
+
const closedStreamGuard = [
|
|
9
|
+
String.raw` attachTo (protomux) {
|
|
10
|
+
if (this.core.closed) return
|
|
11
|
+
`,
|
|
12
|
+
String.raw` attachTo (protomux) {
|
|
13
|
+
if (this.core.closed) return
|
|
14
|
+
if (protomux.stream.destroying) return
|
|
15
|
+
`
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
version: '11.8.3',
|
|
20
|
+
file: 'lib/replicator.js',
|
|
21
|
+
originalSha256: '1b4ddcd0d95d804b84a17a21b9076f990b133f78254b5255e5d32f0951cacf15',
|
|
22
|
+
patchedSha256: '0456d47322a34852e2c9244a0128971d8358fe7575d584e0f025eca3db5f654c',
|
|
23
|
+
// Only this exact released v1 file is eligible for an in-place upgrade.
|
|
24
|
+
previousPatchedSha256: '078afd835dc3e9cb0f7ee294a260528f633c05b54f8bf8276dd3d94aef15248b',
|
|
25
|
+
upgradeEdits: [
|
|
26
|
+
['// OPENMAYHEM-STALLFIX v1 (hypercore 11.8.3).', '// OPENMAYHEM-STALLFIX v2 (hypercore 11.8.3).'],
|
|
27
|
+
["const STALLFIX = 'openmayhem-stallfix-v1'", "const STALLFIX = 'openmayhem-stallfix-v2'"],
|
|
28
|
+
closedStreamGuard
|
|
29
|
+
],
|
|
30
|
+
edits: [
|
|
31
|
+
// 1. Constants + marker.
|
|
32
|
+
[
|
|
33
|
+
String.raw`const MAX_RANGES = 64
|
|
34
|
+
`,
|
|
35
|
+
String.raw`const MAX_RANGES = 64
|
|
36
|
+
|
|
37
|
+
// OPENMAYHEM-STALLFIX v2 (hypercore 11.8.3). Install-time patch shipped by trac-msb and trac-peer
|
|
38
|
+
// (scripts/hypercore-stallfix/). Fixes the waiting-side replication stall: an in-flight request that a peer
|
|
39
|
+
// never answers is cancelled after STALLFIX_REQUEST_MAX_AGE and asked elsewhere; that peer gets a cool-down;
|
|
40
|
+
// a peer we paused gets a capped NOT_AVAILABLE instead of silence. Wire format unchanged.
|
|
41
|
+
const STALLFIX = 'openmayhem-stallfix-v2'
|
|
42
|
+
const STALLFIX_REQUEST_MAX_AGE = 30000 // ms an in-flight request may wait for its answer
|
|
43
|
+
const STALLFIX_SWEEP_INTERVAL = 5000 // one unref'd timer per replicator, only while requests are in flight
|
|
44
|
+
const STALLFIX_MAX_SCAN = 4096 // request slots looked at per sweep
|
|
45
|
+
const STALLFIX_MAX_EXPIRE = 64 // requests expired per sweep
|
|
46
|
+
const STALLFIX_COOLDOWN_MIN = 30000 // first cool-down of a peer that let a request expire (doubles, capped)
|
|
47
|
+
const STALLFIX_COOLDOWN_MAX = 240000
|
|
48
|
+
const STALLFIX_NOT_AVAILABLE_WINDOW = 60000
|
|
49
|
+
const STALLFIX_NOT_AVAILABLE_MAX = 16 // NOT_AVAILABLE replies per window to a peer we paused; silent beyond
|
|
50
|
+
`
|
|
51
|
+
],
|
|
52
|
+
// 2. Per-peer state.
|
|
53
|
+
[
|
|
54
|
+
String.raw` this.lastExtensionRecv = ''
|
|
55
|
+
|
|
56
|
+
replicator._ifAvailable++
|
|
57
|
+
replicator._active++
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
String.raw` this.lastExtensionRecv = ''
|
|
61
|
+
|
|
62
|
+
this._sfTimeouts = 0 // OPENMAYHEM-STALLFIX: cool-down level of this peer
|
|
63
|
+
this._sfCoolUntil = 0
|
|
64
|
+
this._sfNaWindow = 0
|
|
65
|
+
this._sfNaCount = 0
|
|
66
|
+
|
|
67
|
+
replicator._ifAvailable++
|
|
68
|
+
replicator._active++
|
|
69
|
+
}
|
|
70
|
+
`
|
|
71
|
+
],
|
|
72
|
+
// 3. Responder: reply "no data" (capped) to a peer we paused instead of dropping the request silently.
|
|
73
|
+
// Also covers proof === null for an inactive peer (that case threw a TypeError here).
|
|
74
|
+
[
|
|
75
|
+
String.raw` if (!this.isActive() && proof.block !== null) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
String.raw` if (!this.isActive() && (proof === null || proof.block !== null)) {
|
|
80
|
+
// OPENMAYHEM-STALLFIX: upstream returns silently here and the remote waits forever.
|
|
81
|
+
this._sfNotAvailable(req.msg.id)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
`
|
|
85
|
+
],
|
|
86
|
+
// 4. Peer helpers: capped "no data" and request expiry.
|
|
87
|
+
[
|
|
88
|
+
String.raw` _cancelRequest (req) {
|
|
89
|
+
if (req.priority === PRIORITY.CANCELLED) return
|
|
90
|
+
`,
|
|
91
|
+
String.raw` // OPENMAYHEM-STALLFIX: at most STALLFIX_NOT_AVAILABLE_MAX replies per window, silent beyond the cap.
|
|
92
|
+
_sfNotAvailable (id) {
|
|
93
|
+
if (this.removed === true) return
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
if (now - this._sfNaWindow >= STALLFIX_NOT_AVAILABLE_WINDOW) {
|
|
96
|
+
this._sfNaWindow = now
|
|
97
|
+
this._sfNaCount = 0
|
|
98
|
+
}
|
|
99
|
+
if (this._sfNaCount >= STALLFIX_NOT_AVAILABLE_MAX) {
|
|
100
|
+
this.replicator._sfStats.notAvailableSuppressed++
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
this._sfNaCount++
|
|
104
|
+
this.replicator._sfStats.notAvailableSent++
|
|
105
|
+
this.wireNoData.send({ request: id })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// OPENMAYHEM-STALLFIX: give up on one request this peer did not answer in time. Bookkeeping as for a
|
|
109
|
+
// "no data" reply (_clearRequest re-queues a wanted block) plus a wire cancel. The id is only reused
|
|
110
|
+
// after the next roundtrip (as in _cancelRequest), so a late reply finds no request and is dropped
|
|
111
|
+
// in ondata/onnodata: it is not invalid data and does not pause the peer.
|
|
112
|
+
_sfExpireRequest (req, now) {
|
|
113
|
+
if (req.priority === PRIORITY.CANCELLED) return false
|
|
114
|
+
req.priority = PRIORITY.CANCELLED
|
|
115
|
+
|
|
116
|
+
this.inflight--
|
|
117
|
+
this.replicator._requestDone(req.id, false)
|
|
118
|
+
|
|
119
|
+
if (this.roundtripQueue === null) this.roundtripQueue = new RoundtripQueue()
|
|
120
|
+
this.roundtripQueue.add(req.id)
|
|
121
|
+
if (this.removed === false) {
|
|
122
|
+
this.wireCancel.send({ request: req.id })
|
|
123
|
+
incrementTx(this.stats.wireCancel, this.replicator.stats.wireCancel)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (this._sfCoolUntil <= now) {
|
|
127
|
+
if (this._sfTimeouts < 8) this._sfTimeouts++
|
|
128
|
+
const cooldown = this.replicator._sfCooldownMin * 2 ** (this._sfTimeouts - 1)
|
|
129
|
+
this._sfCoolUntil = now + Math.min(STALLFIX_COOLDOWN_MAX, cooldown)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.replicator._manifestPeer === this && this.core.header.manifest === null) {
|
|
133
|
+
this.replicator._manifestPeer = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.replicator._clearRequest(this, req)
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_cancelRequest (req) {
|
|
141
|
+
if (req.priority === PRIORITY.CANCELLED) return
|
|
142
|
+
`
|
|
143
|
+
],
|
|
144
|
+
// 5. Arm the sweep when a request goes in flight.
|
|
145
|
+
[
|
|
146
|
+
String.raw` this.inflight++
|
|
147
|
+
this.replicator._inflight.add(req)
|
|
148
|
+
`,
|
|
149
|
+
String.raw` this.inflight++
|
|
150
|
+
this.replicator._inflight.add(req)
|
|
151
|
+
if (this.replicator._sfTimer === null) this.replicator._sfArm() // OPENMAYHEM-STALLFIX
|
|
152
|
+
`
|
|
153
|
+
],
|
|
154
|
+
// 6. Marker on the Replicator class (runtime check: core.replicator.constructor.STALLFIX).
|
|
155
|
+
[
|
|
156
|
+
String.raw` static Peer = Peer // hack to be able to access Peer from outside this module
|
|
157
|
+
`,
|
|
158
|
+
String.raw` static Peer = Peer // hack to be able to access Peer from outside this module
|
|
159
|
+
static STALLFIX = STALLFIX // OPENMAYHEM-STALLFIX marker
|
|
160
|
+
`
|
|
161
|
+
],
|
|
162
|
+
// 7. Replicator state.
|
|
163
|
+
[
|
|
164
|
+
String.raw` this._notDownloadingLinger = notDownloadingLinger
|
|
165
|
+
this._downloadingTimer = null
|
|
166
|
+
`,
|
|
167
|
+
String.raw` this._notDownloadingLinger = notDownloadingLinger
|
|
168
|
+
this._downloadingTimer = null
|
|
169
|
+
|
|
170
|
+
// OPENMAYHEM-STALLFIX state (the _sf* numbers are only changed by tests)
|
|
171
|
+
this._sfTimer = null
|
|
172
|
+
this._sfCursor = 0
|
|
173
|
+
this._sfAllCooling = false
|
|
174
|
+
this._sfMaxAge = STALLFIX_REQUEST_MAX_AGE
|
|
175
|
+
this._sfInterval = STALLFIX_SWEEP_INTERVAL
|
|
176
|
+
this._sfCooldownMin = STALLFIX_COOLDOWN_MIN
|
|
177
|
+
this._sfStats = { expired: 0, notAvailableSent: 0, notAvailableSuppressed: 0 }
|
|
178
|
+
`
|
|
179
|
+
],
|
|
180
|
+
// 8. Valid block data from a peer ends its cool-down.
|
|
181
|
+
[
|
|
182
|
+
String.raw` _ondata (peer, req, data) {
|
|
183
|
+
req.elapsed = Date.now() - req.timestamp
|
|
184
|
+
if (data.block !== null) {
|
|
185
|
+
`,
|
|
186
|
+
String.raw` _ondata (peer, req, data) {
|
|
187
|
+
req.elapsed = Date.now() - req.timestamp
|
|
188
|
+
if (data.block !== null) {
|
|
189
|
+
peer._sfTimeouts = 0 // OPENMAYHEM-STALLFIX
|
|
190
|
+
peer._sfCoolUntil = 0
|
|
191
|
+
`
|
|
192
|
+
],
|
|
193
|
+
// 9. The sweep (bounded work, one timer per replicator).
|
|
194
|
+
[
|
|
195
|
+
String.raw` _maybeUpdate () {
|
|
196
|
+
return this._upgrade !== null && this._upgrade.inflight.length === 0
|
|
197
|
+
}
|
|
198
|
+
`,
|
|
199
|
+
String.raw` _maybeUpdate () {
|
|
200
|
+
return this._upgrade !== null && this._upgrade.inflight.length === 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// OPENMAYHEM-STALLFIX: one unref'd timer per replicator, armed by _send, re-armed only while requests are
|
|
204
|
+
// in flight or a peer is cooling down. Looks at <= STALLFIX_MAX_SCAN slots, expires <= STALLFIX_MAX_EXPIRE.
|
|
205
|
+
_sfArm () {
|
|
206
|
+
if (this.destroyed || this._sfTimer !== null) return
|
|
207
|
+
this._sfTimer = setTimeout(sfSweepLater, this._sfInterval, this)
|
|
208
|
+
if (this._sfTimer.unref) this._sfTimer.unref()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_sfSweep () {
|
|
212
|
+
this._sfTimer = null
|
|
213
|
+
if (this.destroyed) return
|
|
214
|
+
|
|
215
|
+
const now = Date.now()
|
|
216
|
+
const reqs = this._inflight._requests
|
|
217
|
+
const scan = Math.min(reqs.length, STALLFIX_MAX_SCAN)
|
|
218
|
+
let live = 0
|
|
219
|
+
let expired = 0
|
|
220
|
+
|
|
221
|
+
for (let i = 0; i < scan && expired < STALLFIX_MAX_EXPIRE; i++) {
|
|
222
|
+
if (this._sfCursor >= reqs.length) this._sfCursor = 0
|
|
223
|
+
const req = reqs[this._sfCursor++]
|
|
224
|
+
if (!req) continue
|
|
225
|
+
live++
|
|
226
|
+
if (now - req.timestamp < this._sfMaxAge) continue
|
|
227
|
+
if (req.peer._sfExpireRequest(req, now) === true) expired++
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let cooling = 0
|
|
231
|
+
let usable = 0
|
|
232
|
+
let cooled = false
|
|
233
|
+
for (const peer of this.peers) {
|
|
234
|
+
if (peer._sfCoolUntil !== 0 && peer._sfCoolUntil <= now) {
|
|
235
|
+
peer._sfCoolUntil = 0
|
|
236
|
+
cooled = true
|
|
237
|
+
}
|
|
238
|
+
if (peer._sfCoolUntil !== 0) cooling++
|
|
239
|
+
else if (peer.isActive()) usable++
|
|
240
|
+
}
|
|
241
|
+
// if no other peer is usable, a cooling peer may still be asked (no deadlock with a single peer)
|
|
242
|
+
this._sfAllCooling = cooling > 0 && usable === 0
|
|
243
|
+
|
|
244
|
+
if (expired > 0) this._sfStats.expired += expired
|
|
245
|
+
if (expired > 0 || cooled) this.updateAll()
|
|
246
|
+
|
|
247
|
+
if (live > 0 || cooling > 0 || scan < reqs.length) this._sfArm()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_sfCoolingDown (peer) {
|
|
251
|
+
return peer._sfCoolUntil !== 0 && this._sfAllCooling === false && peer._sfCoolUntil > Date.now()
|
|
252
|
+
}
|
|
253
|
+
`
|
|
254
|
+
],
|
|
255
|
+
// 10. Hotswap does not pick a cooling peer.
|
|
256
|
+
[
|
|
257
|
+
String.raw` if (!peer.isActive() || peer.inflight >= maxHotswaps) return
|
|
258
|
+
`,
|
|
259
|
+
String.raw` if (!peer.isActive() || peer.inflight >= maxHotswaps || this._sfCoolingDown(peer)) return
|
|
260
|
+
`
|
|
261
|
+
],
|
|
262
|
+
// 11. _updatePeer: skip a cooling peer + backport upstream 8cf8ad9 fix 2 (stale queue entry must not
|
|
263
|
+
// consume the peer's update).
|
|
264
|
+
[
|
|
265
|
+
String.raw` _updatePeer (peer) {
|
|
266
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight()) {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
`,
|
|
270
|
+
String.raw` _updatePeer (peer) {
|
|
271
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight() || this._sfCoolingDown(peer)) {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
`
|
|
275
|
+
],
|
|
276
|
+
[
|
|
277
|
+
String.raw` for (const b of blks) {
|
|
278
|
+
if (b.queued === false || peer._requestBlock(b) === true) {
|
|
279
|
+
b.queued = false
|
|
280
|
+
blks.dequeue()
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
`,
|
|
285
|
+
String.raw` for (const b of blks) {
|
|
286
|
+
if (b.queued === false) {
|
|
287
|
+
// upstream 8cf8ad9 (#896) fix 2, backported by OPENMAYHEM-STALLFIX
|
|
288
|
+
blks.dequeue()
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (peer._requestBlock(b) === true) {
|
|
292
|
+
b.queued = false
|
|
293
|
+
blks.dequeue()
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
`
|
|
298
|
+
],
|
|
299
|
+
// 12. _updatePeerNonPrimary: skip a cooling peer.
|
|
300
|
+
[
|
|
301
|
+
String.raw` _updatePeerNonPrimary (peer) {
|
|
302
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight()) {
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
`,
|
|
306
|
+
String.raw` _updatePeerNonPrimary (peer) {
|
|
307
|
+
if (!peer.isActive() || peer.inflight >= peer.getMaxInflight() || this._sfCoolingDown(peer)) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
`
|
|
311
|
+
],
|
|
312
|
+
// 13. destroy() clears the sweep timer.
|
|
313
|
+
[
|
|
314
|
+
String.raw` if (this._downloadingTimer) {
|
|
315
|
+
clearTimeout(this._downloadingTimer)
|
|
316
|
+
this._downloadingTimer = null
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
while (this.peers.length) {
|
|
320
|
+
`,
|
|
321
|
+
String.raw` if (this._downloadingTimer) {
|
|
322
|
+
clearTimeout(this._downloadingTimer)
|
|
323
|
+
this._downloadingTimer = null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this._sfTimer !== null) {
|
|
327
|
+
clearTimeout(this._sfTimer) // OPENMAYHEM-STALLFIX
|
|
328
|
+
this._sfTimer = null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
while (this.peers.length) {
|
|
332
|
+
`
|
|
333
|
+
],
|
|
334
|
+
// 14. Timer trampoline.
|
|
335
|
+
[
|
|
336
|
+
String.raw`function setDownloadingLater (repl, downloading, session) {
|
|
337
|
+
`,
|
|
338
|
+
String.raw`function sfSweepLater (repl) {
|
|
339
|
+
repl._sfSweep() // OPENMAYHEM-STALLFIX
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function setDownloadingLater (repl, downloading, session) {
|
|
343
|
+
`
|
|
344
|
+
],
|
|
345
|
+
// Upstream #911, in addition to the existing stall fixes.
|
|
346
|
+
closedStreamGuard
|
|
347
|
+
]
|
|
348
|
+
}
|
package/src/config/config.js
CHANGED
|
@@ -219,6 +219,16 @@ export class Config {
|
|
|
219
219
|
return this.#config.validatorHealthCheckFailureThreshold
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
get confirmationStallTimeout() {
|
|
223
|
+
if (this.#isOverriden('confirmationStallTimeout')) return this.#options.confirmationStallTimeout
|
|
224
|
+
return this.#config.confirmationStallTimeout
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get replicationStallTimeout() {
|
|
228
|
+
if (this.#isOverriden('replicationStallTimeout')) return this.#options.replicationStallTimeout
|
|
229
|
+
return this.#config.replicationStallTimeout
|
|
230
|
+
}
|
|
231
|
+
|
|
222
232
|
get maxPendingRequestsInPendingRequestsService() {
|
|
223
233
|
return this.#config.maxPendingRequestsInPendingRequestsService
|
|
224
234
|
}
|
package/src/config/env.js
CHANGED
|
@@ -57,6 +57,8 @@ const configData = {
|
|
|
57
57
|
txPoolSize: 1000, // size of transaction pool
|
|
58
58
|
validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
|
|
59
59
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
60
|
+
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
61
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
60
62
|
storesDirectory: 'stores/',
|
|
61
63
|
storeName: 'testnet',
|
|
62
64
|
},
|
|
@@ -107,6 +109,8 @@ const configData = {
|
|
|
107
109
|
txPoolSize: 1000, // size of transaction pool
|
|
108
110
|
validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
|
|
109
111
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
112
|
+
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
113
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
110
114
|
storesDirectory: 'stores/',
|
|
111
115
|
storeName: 'mainnet',
|
|
112
116
|
},
|
|
@@ -157,6 +161,8 @@ const configData = {
|
|
|
157
161
|
txPoolSize: 1000, // size of transaction pool
|
|
158
162
|
validatorHealthCheckInterval: 1_000, // How often to check validator health (ms)
|
|
159
163
|
validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
|
|
164
|
+
confirmationStallTimeout: 120_000, // Force a fast-forward when the confirmed length has not moved for this long while unconfirmed entries wait (ms, 0 = off)
|
|
165
|
+
replicationStallTimeout: 600_000, // Close all MSB connections once (max 1 per 10 min) when signed system data stays unapplied and the confirmed length has not moved for this long (ms, 0 = off)
|
|
160
166
|
storesDirectory: 'stores/',
|
|
161
167
|
storeName: 'development',
|
|
162
168
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const DEFAULT_CHECK_INTERVAL = 15_000;
|
|
2
|
+
const DEFAULT_MIN_GAP = 600_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Safety net for the Hypercore replication stall (block requests that a peer never answers; the install-time
|
|
6
|
+
* patch in scripts/hypercore-stallfix/ is the actual fix). Signal: at every check for `timeout` ms this node
|
|
7
|
+
* knew signed system data it had not applied (base.core.core.state.length > base.core.length) while its
|
|
8
|
+
* confirmed length (base.view.core.signedLength) did not move. Then it closes ALL connections of the MSB swarm
|
|
9
|
+
* once, pinned validator links included, and lets discovery reconnect: the transport reset a restart does,
|
|
10
|
+
* without a restart. At most once per `minGap`, and only after a new full `timeout` window.
|
|
11
|
+
*
|
|
12
|
+
* Never runs on an indexer. It only closes connections: roles, transactions, balances and state are untouched.
|
|
13
|
+
*/
|
|
14
|
+
export class ReplicationStallGuard {
|
|
15
|
+
#base;
|
|
16
|
+
#getSwarm;
|
|
17
|
+
#refresh;
|
|
18
|
+
#timeout;
|
|
19
|
+
#interval;
|
|
20
|
+
#minGap;
|
|
21
|
+
#now;
|
|
22
|
+
#log;
|
|
23
|
+
#timer = null;
|
|
24
|
+
#mark = null;
|
|
25
|
+
#stalledSince = null;
|
|
26
|
+
#lastReconnectAt = null;
|
|
27
|
+
#lastClosed = 0;
|
|
28
|
+
#reconnects = 0;
|
|
29
|
+
#last = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} base Autobase instance of MSB
|
|
33
|
+
* @param {() => object|null} getSwarm returns the MSB Hyperswarm (or null)
|
|
34
|
+
* @param {object} options
|
|
35
|
+
*/
|
|
36
|
+
constructor(base, getSwarm, {
|
|
37
|
+
timeout,
|
|
38
|
+
interval = DEFAULT_CHECK_INTERVAL,
|
|
39
|
+
minGap = DEFAULT_MIN_GAP,
|
|
40
|
+
refresh = null,
|
|
41
|
+
now = Date.now,
|
|
42
|
+
log = console.log
|
|
43
|
+
} = {}) {
|
|
44
|
+
this.#base = base;
|
|
45
|
+
this.#getSwarm = getSwarm;
|
|
46
|
+
this.#refresh = refresh;
|
|
47
|
+
this.#timeout = timeout;
|
|
48
|
+
this.#interval = Number.isSafeInteger(timeout) && timeout > 0 ? Math.min(interval, timeout) : interval;
|
|
49
|
+
this.#minGap = minGap;
|
|
50
|
+
this.#now = now;
|
|
51
|
+
this.#log = log;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get enabled() {
|
|
55
|
+
return Number.isSafeInteger(this.#timeout) && this.#timeout > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
start() {
|
|
59
|
+
if (!this.enabled || this.#timer !== null) return;
|
|
60
|
+
this.#timer = setInterval(() => { this.check(); }, this.#interval);
|
|
61
|
+
this.#timer.unref?.();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop() {
|
|
65
|
+
if (this.#timer !== null) clearInterval(this.#timer);
|
|
66
|
+
this.#timer = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
status() {
|
|
70
|
+
const last = this.#last;
|
|
71
|
+
let state = 'ok';
|
|
72
|
+
if (this.#stalledSince !== null) state = 'stalled';
|
|
73
|
+
else if (this.#mark !== null) state = 'behind';
|
|
74
|
+
return {
|
|
75
|
+
enabled: this.enabled,
|
|
76
|
+
state,
|
|
77
|
+
confirmedLength: last?.confirmed ?? null,
|
|
78
|
+
systemLength: last?.systemLength ?? null,
|
|
79
|
+
systemSignedLength: last?.systemSignedLength ?? null,
|
|
80
|
+
behindSince: this.#mark?.since ?? null,
|
|
81
|
+
stalledSince: this.#stalledSince,
|
|
82
|
+
reconnects: this.#reconnects,
|
|
83
|
+
lastReconnectAt: this.#lastReconnectAt,
|
|
84
|
+
lastReconnectClosed: this.#lastClosed
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#emit(event, fields) {
|
|
89
|
+
try { this.#log(`[MSB] ${JSON.stringify({ event, ...fields })}`); } catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#sample() {
|
|
93
|
+
const base = this.#base;
|
|
94
|
+
const confirmed = base?.view?.core?.signedLength;
|
|
95
|
+
const systemLength = base?.core?.length;
|
|
96
|
+
const systemSignedLength = base?.core?.core?.state?.length;
|
|
97
|
+
if (![confirmed, systemLength, systemSignedLength].every(Number.isSafeInteger)) return null;
|
|
98
|
+
return { confirmed, systemLength, systemSignedLength };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {boolean} true when the connections were closed */
|
|
102
|
+
check() {
|
|
103
|
+
const base = this.#base;
|
|
104
|
+
if (!this.enabled || !base || base.closing || base.closed || base.isIndexer) {
|
|
105
|
+
this.#mark = null;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const s = this.#sample();
|
|
109
|
+
if (s === null) return false;
|
|
110
|
+
this.#last = s;
|
|
111
|
+
|
|
112
|
+
const now = this.#now();
|
|
113
|
+
const ahead = s.systemSignedLength > s.systemLength;
|
|
114
|
+
const moved = this.#mark !== null && this.#mark.confirmed !== s.confirmed;
|
|
115
|
+
|
|
116
|
+
if (this.#stalledSince !== null && (!ahead || moved)) {
|
|
117
|
+
this.#emit('msb_replication_recovered', {
|
|
118
|
+
confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
119
|
+
systemSignedLength: s.systemSignedLength, stalledMs: now - this.#stalledSince
|
|
120
|
+
});
|
|
121
|
+
this.#stalledSince = null;
|
|
122
|
+
}
|
|
123
|
+
if (!ahead) {
|
|
124
|
+
this.#mark = null;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (this.#mark === null || moved || now < this.#mark.since) {
|
|
128
|
+
this.#mark = { confirmed: s.confirmed, since: now };
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const elapsed = now - this.#mark.since;
|
|
133
|
+
if (elapsed < this.#timeout) return false;
|
|
134
|
+
|
|
135
|
+
if (this.#stalledSince === null) {
|
|
136
|
+
this.#stalledSince = this.#mark.since;
|
|
137
|
+
this.#emit('msb_replication_stalled', {
|
|
138
|
+
confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
139
|
+
systemSignedLength: s.systemSignedLength, stalledMs: elapsed
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (this.#lastReconnectAt !== null && now - this.#lastReconnectAt < this.#minGap) return false;
|
|
143
|
+
|
|
144
|
+
const closed = this.#reconnectAll();
|
|
145
|
+
this.#lastReconnectAt = now;
|
|
146
|
+
this.#lastClosed = closed;
|
|
147
|
+
this.#reconnects++;
|
|
148
|
+
this.#emit('msb_replication_reconnect_all', {
|
|
149
|
+
closedConnections: closed, confirmedLength: s.confirmed, systemLength: s.systemLength,
|
|
150
|
+
systemSignedLength: s.systemSignedLength, stalledMs: now - this.#stalledSince, reconnects: this.#reconnects
|
|
151
|
+
});
|
|
152
|
+
// the next action needs a new full window
|
|
153
|
+
this.#mark = { confirmed: s.confirmed, since: now };
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#reconnectAll() {
|
|
158
|
+
const swarm = this.#getSwarm?.();
|
|
159
|
+
if (!swarm || swarm.destroyed || swarm.suspended || !swarm.connections) return 0;
|
|
160
|
+
let closed = 0;
|
|
161
|
+
for (const connection of [...swarm.connections]) {
|
|
162
|
+
if (connection.destroyed || connection.destroying) continue;
|
|
163
|
+
try {
|
|
164
|
+
connection.destroy();
|
|
165
|
+
closed++;
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
if (this.#refresh !== null) {
|
|
169
|
+
try {
|
|
170
|
+
Promise.resolve(this.#refresh()).catch(() => {});
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return closed;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default ReplicationStallGuard;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const DEFAULT_CHECK_INTERVAL = 15_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Autobase starts a follower's fast-forward only when the network's signed length is at least
|
|
5
|
+
* 16 entries past the follower's total length, unconfirmed entries included. After a writer is
|
|
6
|
+
* removed in the middle of a batch, a follower that already holds unconfirmed entries can stay
|
|
7
|
+
* above that mark: its confirmed length then stops for good, and a restart does not help.
|
|
8
|
+
*
|
|
9
|
+
* This watch forces one fast-forward when the confirmed length has not moved for `timeout` ms
|
|
10
|
+
* while unconfirmed entries wait. It never runs on an indexer and does not change how entries are
|
|
11
|
+
* applied.
|
|
12
|
+
*/
|
|
13
|
+
export class ConfirmationWatch {
|
|
14
|
+
#base;
|
|
15
|
+
#timeout;
|
|
16
|
+
#interval;
|
|
17
|
+
#now;
|
|
18
|
+
#log;
|
|
19
|
+
#timer = null;
|
|
20
|
+
#mark = null;
|
|
21
|
+
#running = false;
|
|
22
|
+
|
|
23
|
+
constructor(base, { timeout, interval = DEFAULT_CHECK_INTERVAL, now = Date.now, log = console.log } = {}) {
|
|
24
|
+
this.#base = base;
|
|
25
|
+
this.#timeout = timeout;
|
|
26
|
+
this.#interval = Math.min(interval, timeout);
|
|
27
|
+
this.#now = now;
|
|
28
|
+
this.#log = log;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get enabled() {
|
|
32
|
+
return Number.isFinite(this.#timeout) && this.#timeout > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start() {
|
|
36
|
+
if (!this.enabled || this.#timer !== null) return;
|
|
37
|
+
this.#timer = setInterval(() => { this.check().catch(() => {}) }, this.#interval);
|
|
38
|
+
this.#timer.unref?.();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop() {
|
|
42
|
+
if (this.#timer !== null) clearInterval(this.#timer);
|
|
43
|
+
this.#timer = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @returns {Promise<boolean>} true when a fast-forward was forced */
|
|
47
|
+
async check() {
|
|
48
|
+
const base = this.#base;
|
|
49
|
+
if (this.#running || base.closing || base.isIndexer) return false;
|
|
50
|
+
if (base.isFastForwarding?.()) {
|
|
51
|
+
this.#mark = null;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const confirmed = base.view.core.signedLength;
|
|
56
|
+
const total = base.view.core.length;
|
|
57
|
+
const now = this.#now();
|
|
58
|
+
|
|
59
|
+
if (total <= confirmed) {
|
|
60
|
+
this.#mark = null;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (this.#mark === null || this.#mark.confirmed !== confirmed) {
|
|
64
|
+
this.#mark = { confirmed, since: now };
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (now - this.#mark.since < this.#timeout) return false;
|
|
68
|
+
|
|
69
|
+
this.#running = true;
|
|
70
|
+
try {
|
|
71
|
+
this.#log(`[MSB] Confirmed length stuck at ${confirmed} (local ${total}) for ${Math.round((now - this.#mark.since) / 1000)} s; forcing a fast-forward`);
|
|
72
|
+
await base.forceFastForward();
|
|
73
|
+
this.#log(`[MSB] Fast-forward finished: confirmed ${base.view.core.signedLength}, local ${base.view.core.length}`);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.#log(`[MSB] Fast-forward failed: ${error?.message ?? error}`);
|
|
76
|
+
} finally {
|
|
77
|
+
this.#running = false;
|
|
78
|
+
this.#mark = { confirmed: base.view.core.signedLength, since: this.#now() };
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/core/state/State.js
CHANGED
|
@@ -42,6 +42,7 @@ import { deepCopyBuffer } from '../../utils/buffer.js';
|
|
|
42
42
|
import { Status } from './utils/transaction.js';
|
|
43
43
|
import remote from 'hypercore/lib/fully-remote-proof.js'
|
|
44
44
|
import PQueue from 'p-queue';
|
|
45
|
+
import { ConfirmationWatch } from './ConfirmationWatch.js';
|
|
45
46
|
|
|
46
47
|
const OVERSIZED_BATCH_PENALTY_MULTIPLIER = BATCH_SIZE;
|
|
47
48
|
|
|
@@ -56,6 +57,7 @@ class State extends ReadyResource {
|
|
|
56
57
|
#config
|
|
57
58
|
#wallet
|
|
58
59
|
#activeWriterCountCache = new Map();
|
|
60
|
+
#confirmationWatch = null;
|
|
59
61
|
|
|
60
62
|
/**
|
|
61
63
|
* @param {Corestore} store
|
|
@@ -96,10 +98,13 @@ class State extends ReadyResource {
|
|
|
96
98
|
console.log("State initialization...")
|
|
97
99
|
await this.#base.ready();
|
|
98
100
|
this.#writingKey = this.#base.local.key;
|
|
101
|
+
this.#confirmationWatch = new ConfirmationWatch(this.#base, { timeout: this.#config.confirmationStallTimeout });
|
|
102
|
+
this.#confirmationWatch.start();
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
async _close() {
|
|
102
106
|
console.log("State: closing gracefully...");
|
|
107
|
+
this.#confirmationWatch?.stop();
|
|
103
108
|
if (this.#bee !== null) {
|
|
104
109
|
await this.#bee.close();
|
|
105
110
|
}
|