Skip to content

Commit 974a6d7

Browse files
committed
problem: lags with head info if WS connection dropped
solution: reconnect to WS after a connection error
1 parent 91edafe commit 974a6d7

File tree

6 files changed

+147
-33
lines changed

6 files changed

+147
-33
lines changed

build.gradle

+8-3
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ dependencies {
7070
implementation "io.netty:netty-transport:$nettyVersion"
7171
implementation "io.netty:netty-common:$nettyVersion"
7272
implementation "io.netty:netty-handler:$nettyVersion"
73-
implementation "io.netty:netty-tcnative:2.0.30.Final:linux-x86_64@jar"
74-
implementation "io.netty:netty-tcnative-boringssl-static:2.0.30.Final:linux-x86_64@jar"
73+
implementation "io.netty:netty-handler-proxy:$nettyVersion"
74+
implementation "io.netty:netty-codec:$nettyVersion"
75+
implementation "io.netty:netty-codec-http2:$nettyVersion"
76+
implementation "io.netty:netty-codec-http:$nettyVersion"
77+
implementation "io.netty:netty-buffer:$nettyVersion"
78+
implementation "io.netty:netty-tcnative:2.0.39.Final:linux-x86_64@jar"
79+
implementation "io.netty:netty-tcnative-boringssl-static:2.0.39.Final:linux-x86_64@jar"
7580

7681
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
7782
implementation "org.jetbrains.kotlin:kotlin-reflect"
@@ -83,7 +88,7 @@ dependencies {
8388
implementation "org.springframework.security:spring-security-web:$springSecurtyVersion"
8489
implementation "org.springframework.security:spring-security-config:$springSecurtyVersion"
8590
implementation "io.projectreactor:reactor-core:$reactorVersion"
86-
implementation "io.projectreactor.netty:reactor-netty:1.0.6"
91+
implementation "io.projectreactor.netty:reactor-netty:1.0.7"
8792
implementation 'io.projectreactor.addons:reactor-extra:3.4.3'
8893
implementation 'io.projectreactor.kotlin:reactor-kotlin-extensions:1.1.3'
8994
implementation "com.salesforce.servicelibs:reactor-grpc-stub:$reactiveGrpcVersion"

gradle.properties

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ protocVersion=3.9.0
55
# Main Libs
66
slf4jVersion=1.7.25
77
jacksonVersion=2.11.0
8-
grpcVersion=1.25.0
8+
grpcVersion=1.38.0
99
reactiveGrpcVersion=1.0.1
1010
springBootVersion=2.4.5
1111
springVersion=5.3.6
1212
springSecurtyVersion=5.4.6
1313
reactorVersion=3.4.5
14-
nettyVersion=4.1.53.Final
14+
nettyVersion=4.1.65.Final
1515
# Our Libs
1616
etherjarVersion=0.10.2
1717
# Testing

src/main/kotlin/io/emeraldpay/dshackle/Global.kt

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspentDeserializer
2626
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
2727
import java.text.SimpleDateFormat
2828
import java.util.*
29+
import java.util.concurrent.Executors
30+
import java.util.concurrent.ScheduledExecutorService
2931

3032
class Global {
3133

@@ -36,6 +38,8 @@ class Global {
3638
@JvmStatic
3739
val objectMapper: ObjectMapper = createObjectMapper()
3840

41+
val control: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
42+
3943
private fun createObjectMapper(): ObjectMapper {
4044
val module = SimpleModule("EmeraldDshackle", Version(1, 0, 0, null, null, null))
4145
module.addSerializer(JsonRpcResponse::class.java, JsonRpcResponse.ResponseJsonSerializer())

src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumWsFactory.kt

+125-25
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,31 @@
1717
package io.emeraldpay.dshackle.upstream.ethereum
1818

1919
import io.emeraldpay.dshackle.Defaults
20+
import io.emeraldpay.dshackle.Global
2021
import io.emeraldpay.dshackle.SilentException
2122
import io.emeraldpay.dshackle.config.AuthConfig
2223
import io.emeraldpay.dshackle.data.BlockContainer
2324
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
2425
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
2526
import io.infinitape.etherjar.rpc.json.BlockJson
2627
import io.infinitape.etherjar.rpc.json.TransactionRefJson
27-
import io.infinitape.etherjar.rpc.ws.WebsocketClient
28+
import io.infinitape.etherjar.rpc.ws.SubscriptionJson
29+
import io.netty.buffer.ByteBufInputStream
30+
import io.netty.handler.codec.http.HttpHeaderNames
2831
import org.slf4j.LoggerFactory
32+
import reactor.core.Disposable
2933
import reactor.core.publisher.Flux
3034
import reactor.core.publisher.Mono
31-
import reactor.extra.processor.TopicProcessor
35+
import reactor.core.publisher.Sinks
36+
import reactor.netty.http.client.HttpClient
37+
import reactor.netty.http.client.WebsocketClientSpec
3238
import reactor.retry.Repeat
39+
import java.io.InputStream
3340
import java.net.URI
3441
import java.time.Duration
42+
import java.util.*
43+
import java.util.concurrent.TimeUnit
44+
import java.util.concurrent.atomic.AtomicReference
3545

3646
class EthereumWsFactory(
3747
private val uri: URI,
@@ -49,36 +59,119 @@ class EthereumWsFactory(
4959
private val origin: URI,
5060
private val upstream: EthereumUpstream,
5161
private val basicAuth: AuthConfig.ClientBasicAuth?
52-
) {
62+
) : AutoCloseable {
5363

5464
companion object {
5565
private val log = LoggerFactory.getLogger(EthereumWs::class.java)
66+
67+
private const val START_REQUEST = "{\"jsonrpc\":\"2.0\", \"method\":\"eth_subscribe\", \"id\":\"blocks\", \"params\":[\"newHeads\"]}"
5668
}
5769

58-
private val topic = TopicProcessor
59-
.builder<BlockContainer>()
60-
.name("new-blocks")
61-
.build()
70+
private val topic = Sinks
71+
.many()
72+
.unicast()
73+
.onBackpressureBuffer<BlockContainer>()
74+
private var keepConnection = true
75+
private var connection: Disposable? = null
6276

6377
fun connect() {
64-
log.info("Connecting to WebSocket: $uri")
65-
val clientBuilder = WebsocketClient.newBuilder()
66-
.connectTo(uri)
67-
.origin(origin)
68-
basicAuth?.let { auth ->
69-
clientBuilder.basicAuth(auth.username, auth.password)
70-
}
71-
val client = clientBuilder.build()
72-
try {
73-
client.connect()
74-
client.onNewBlock(this::onNewBlock)
75-
} catch (e: Exception) {
76-
log.error("Failed to connect to websocket at $uri. Error: ${e.message}")
78+
if (keepConnection) {
79+
connectInternal()
7780
}
7881
}
7982

83+
private fun tryReconnectLater() {
84+
Global.control.schedule(
85+
{ connectInternal() },
86+
Defaults.retryConnection.seconds, TimeUnit.SECONDS)
87+
}
88+
89+
private fun connectInternal() {
90+
log.info("Connecting to WebSocket: $uri")
91+
connection?.dispose()
92+
connection = null
93+
94+
val subscriptionId = AtomicReference<String>("NOTSET")
95+
96+
val objectMapper = Global.objectMapper
97+
connection = HttpClient.create()
98+
.doOnError(
99+
{ _, t ->
100+
log.warn("Failed to connect to $uri. Error: ${t.message}")
101+
// going to try to reconnect later
102+
tryReconnectLater()
103+
},
104+
{ _, _ ->
105+
106+
}
107+
)
108+
.headers { headers ->
109+
headers.add(HttpHeaderNames.ORIGIN, origin)
110+
basicAuth?.let { auth ->
111+
val tmp: String = auth.username + ":" + auth.password
112+
val base64password = Base64.getEncoder().encodeToString(tmp.toByteArray())
113+
headers.add(HttpHeaderNames.AUTHORIZATION, "Basic $base64password")
114+
}
115+
}
116+
.let {
117+
if (uri.scheme == "wss") {
118+
it.secure()
119+
} else {
120+
it
121+
}
122+
}
123+
.websocket(
124+
WebsocketClientSpec.builder()
125+
.handlePing(true)
126+
.compress(false)
127+
.build()
128+
)
129+
130+
.uri(uri)
131+
.handle { inbound, outbound ->
132+
val consumer = inbound.aggregateFrames()
133+
.aggregateFrames(8 * 65_536)
134+
.receiveFrames()
135+
.flatMap {
136+
val msg: SubscriptionJson = objectMapper.readerFor(SubscriptionJson::class.java)
137+
.readValue(ByteBufInputStream(it.content()) as InputStream)
138+
when {
139+
msg.error != null -> {
140+
Mono.error(IllegalStateException("Received error from WS upstream"))
141+
}
142+
msg.subscription == subscriptionId.get() -> {
143+
onNewBlock(msg.blockResult)
144+
Mono.empty<Int>()
145+
}
146+
msg.subscription == null -> {
147+
// received ID for subscription
148+
subscriptionId.set(msg.result.asText())
149+
log.debug("Connected to $uri")
150+
Mono.empty<Int>()
151+
}
152+
else -> {
153+
Mono.error(IllegalStateException("Unknown message received: ${msg.subscription}"))
154+
}
155+
}
156+
}
157+
.onErrorResume { t ->
158+
log.warn("Connection dropped to $uri. Error: ${t.message}")
159+
// going to try to reconnect later
160+
tryReconnectLater()
161+
// completes current outbound flow
162+
Mono.empty()
163+
}
164+
165+
166+
outbound.sendString(Mono.just(START_REQUEST).doOnError {
167+
println("!!!!!!!")
168+
})
169+
.then(consumer.then())
170+
}.subscribe()
171+
}
172+
80173
fun onNewBlock(block: BlockJson<TransactionRefJson>) {
81-
// WS returns incomplete blocks
174+
// WS returns incomplete blocks, i.e. without some fields, so need to fetch full block data
82175
if (block.difficulty == null || block.transactions == null) {
83176
Mono.just(block.hash)
84177
.flatMap { hash ->
@@ -100,16 +193,23 @@ class EthereumWsFactory(
100193
}
101194
.timeout(Defaults.timeout, Mono.empty())
102195
.onErrorResume { Mono.empty() }
103-
.subscribe(topic::onNext)
196+
.subscribe {
197+
topic.tryEmitNext(it)
198+
}
104199

105200
} else {
106-
topic.onNext(BlockContainer.from(block))
201+
topic.tryEmitNext(BlockContainer.from(block))
107202
}
108203
}
109204

110205
fun getFlux(): Flux<BlockContainer> {
111-
return Flux.from(this.topic)
112-
.onBackpressureLatest()
206+
return this.topic.asFlux()
207+
}
208+
209+
override fun close() {
210+
keepConnection = false
211+
connection?.dispose()
212+
connection = null
113213
}
114214
}
115215

src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumWsHead.kt

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class EthereumWsHead(
3838
}
3939

4040
override fun stop() {
41+
ws.close()
4142
subscription?.dispose()
4243
subscription = null
4344
}

src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumWsFactorySpec.groovy

+7-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import io.emeraldpay.dshackle.test.TestingCommons
2121
import io.infinitape.etherjar.domain.BlockHash
2222
import io.infinitape.etherjar.rpc.json.BlockJson
2323
import io.infinitape.etherjar.rpc.json.TransactionRefJson
24+
import reactor.core.publisher.Flux
2425
import reactor.test.StepVerifier
2526
import spock.lang.Specification
2627

@@ -50,12 +51,15 @@ class EthereumWsFactorySpec extends Specification {
5051
apiMock.answerOnce("eth_getBlockByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200", false], block)
5152

5253
when:
53-
ws.onNewBlock(block)
54+
def act = Flux.from(ws.getFlux())
55+
new Thread({
56+
ws.onNewBlock(block)
57+
}).run()
5458

5559
then:
56-
StepVerifier.create(ws.flux.take(1))
60+
StepVerifier.create(act)
5761
.expectNext(BlockContainer.from(block))
58-
.expectComplete()
62+
.thenCancel()
5963
.verify(Duration.ofSeconds(1))
6064
}
6165
}

0 commit comments

Comments
 (0)