Fix silent stalls in native_subscribe WS subscriptions (Solana, eth pending tx) (#827)

This commit is contained in:
a10zn8
2026-05-18 14:53:22 +03:00
committed by GitHub
parent 91d2745382
commit e865173c4d
4 changed files with 115 additions and 11 deletions

View File

@@ -27,6 +27,7 @@ import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.time.Duration import java.time.Duration
import java.util.concurrent.TimeoutException
class WebsocketPendingTxes( class WebsocketPendingTxes(
private val chain: Chain, private val chain: Chain,
@@ -35,6 +36,7 @@ class WebsocketPendingTxes(
companion object { companion object {
private val log = LoggerFactory.getLogger(WebsocketPendingTxes::class.java) private val log = LoggerFactory.getLogger(WebsocketPendingTxes::class.java)
private val IDLE_TIMEOUT: Duration = Duration.ofSeconds(85)
} }
override fun createConnection(): Flux<TransactionId> { override fun createConnection(): Flux<TransactionId> {
@@ -42,7 +44,15 @@ class WebsocketPendingTxes(
return sub return sub
.data .data
.flatMapMany { it.t2 } .flatMapMany { it.t2 }
.timeout(Duration.ofSeconds(85), Mono.empty()) // Surface stalls as TimeoutException so the surrounding DurableFlux re-invokes
// createConnection() and issues a fresh eth_subscribe. Without this, a silent
// upstream pubsub leaves the shared Flux completed and clients stop receiving events.
.timeout(
IDLE_TIMEOUT,
Mono.error(
TimeoutException("No events from eth_subscribe newPendingTransactions in $IDLE_TIMEOUT, forcing resubscribe"),
),
)
.map { .map {
// comes as a JS string, i.e., within quotes // comes as a JS string, i.e., within quotes
val value = ByteArray(it.size - 2) val value = ByteArray(it.size - 2)
@@ -60,7 +70,6 @@ class WebsocketPendingTxes(
log.info("unsubscribed from ${sub.subId.get()}") log.info("unsubscribed from ${sub.subId.get()}")
} }
} }
.doOnError { t -> log.warn("Invalid pending transaction", t) } .doOnError { t -> log.warn("Error during pending tx subscription: {}", t.message) }
.onErrorResume { Mono.empty() }
} }
} }

View File

@@ -13,6 +13,7 @@ import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.time.Duration import java.time.Duration
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeoutException
class GenericIngressSubscription( class GenericIngressSubscription(
val chain: Chain, val chain: Chain,
@@ -49,6 +50,7 @@ class GenericSubscriptionConnect(
companion object { companion object {
private val log = LoggerFactory.getLogger(GenericSubscriptionConnect::class.java) private val log = LoggerFactory.getLogger(GenericSubscriptionConnect::class.java)
private val IDLE_TIMEOUT: Duration = Duration.ofSeconds(85)
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -56,16 +58,16 @@ class GenericSubscriptionConnect(
val sub = conn.subscribe(ChainRequest(topic, ListParams(getParams(params) as List<Any>))) val sub = conn.subscribe(ChainRequest(topic, ListParams(getParams(params) as List<Any>)))
return sub.data return sub.data
.flatMapMany { it.t2 } .flatMapMany { it.t2 }
// Some upstreams (notably Solana RPCs) silently stop delivering events on a subscription
// while keeping the WebSocket connection alive. Emit a TimeoutException so the surrounding
// DurableFlux re-invokes createConnection() and re-issues `subscribe` with a fresh subId.
.timeout( .timeout(
Duration.ofSeconds(85), IDLE_TIMEOUT,
Mono.empty<ByteArray>().doOnEach { Mono.error(
log.warn("Timeout during subscription to $topic after 85 seconds") TimeoutException("No events from subscription to $topic in $IDLE_TIMEOUT, forcing resubscribe"),
}, ),
) )
.onErrorResume { .doOnError { log.warn("Error during subscription to $topic: {}", it.message) }
log.error("Error during subscription to $topic", it)
Mono.empty()
}
.doFinally { .doFinally {
if (unsubscribeMethod != "") { if (unsubscribeMethod != "") {
conn.unsubscribe( conn.unsubscribe(

View File

@@ -22,10 +22,12 @@ import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions
import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import io.emeraldpay.dshackle.upstream.rpcclient.ListParams
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.test.StepVerifier
import reactor.util.function.Tuples import reactor.util.function.Tuples
import spock.lang.Specification import spock.lang.Specification
import java.time.Duration import java.time.Duration
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class WebsocketPendingTxesSpec extends Specification { class WebsocketPendingTxesSpec extends Specification {
@@ -55,4 +57,26 @@ class WebsocketPendingTxesSpec extends Specification {
"0x67f22a3b441ea312306f97694ca8159f8d6faaccf0f5ce6442c84b13991f1d23", "0x67f22a3b441ea312306f97694ca8159f8d6faaccf0f5ce6442c84b13991f1d23",
] ]
} }
def "Emits TimeoutException when upstream goes silent past idle timeout"() {
// Pins the fix for the silent-stall bug: a healthy WS that simply stops delivering
// newPendingTransactions events must surface a TimeoutException so DurableFlux
// re-issues eth_subscribe.
setup:
def ws = Stub(WsSubscriptions) {
subscribe(new ChainRequest("eth_subscribe", new ListParams(["newPendingTransactions"]))) >> new WsSubscriptions.SubscribeData(
Mono.just(Tuples.of("sub-1", Flux.never())), "conn-1", new AtomicReference<String>("sub-1")
)
unsubscribe(_) >> Mono.empty()
}
def pending = new WebsocketPendingTxes(Chain.ETHEREUM__MAINNET, ws)
expect:
StepVerifier.withVirtualTime { pending.createConnection() }
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(84))
.thenAwait(Duration.ofSeconds(2))
.expectError(TimeoutException)
.verify(Duration.ofSeconds(5))
}
} }

View File

@@ -6,13 +6,16 @@ import io.emeraldpay.dshackle.upstream.ethereum.WsSubscriptions
import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import io.emeraldpay.dshackle.upstream.rpcclient.ListParams
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.test.StepVerifier import reactor.test.StepVerifier
import reactor.util.function.Tuples import reactor.util.function.Tuples
import java.time.Duration import java.time.Duration
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class GenericSubscriptionConnectTest { class GenericSubscriptionConnectTest {
@@ -36,4 +39,70 @@ class GenericSubscriptionConnectTest {
verify(ws).subscribe(ChainRequest(topic, ListParams(param))) verify(ws).subscribe(ChainRequest(topic, ListParams(param)))
} }
@Test
fun `emits TimeoutException when upstream goes silent past idle timeout`() {
// Simulates the Solana-like failure: WS subscription is established, but upstream stops
// delivering events without closing the connection. The Flux must surface a TimeoutException
// so the surrounding DurableFlux re-subscribes.
val param: List<Any> = listOf("all")
val topic = "slotSubscribe"
val ws = mock<WsSubscriptions> {
on { subscribe(ChainRequest(topic, ListParams(param))) } doReturn
WsSubscriptions.SubscribeData(
Mono.just(Tuples.of("sub-id-1", Flux.never<ByteArray>())),
"conn-1",
AtomicReference("sub-id-1"),
)
}
val genericSubscriptionConnect = GenericSubscriptionConnect(Chain.SOLANA__MAINNET, ws, topic, param, "")
StepVerifier.withVirtualTime { genericSubscriptionConnect.createConnection() }
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(84))
.thenAwait(Duration.ofSeconds(2))
.expectError(TimeoutException::class.java)
.verify(Duration.ofSeconds(5))
}
@Test
fun `idle timeout triggers resubscribe via DurableFlux retry`() {
// End-to-end on the GenericSubscriptionConnect: when the first subscription stalls,
// DurableFlux must call createConnection() again, which issues a fresh `subscribe` RPC.
val param: List<Any> = listOf("all")
val topic = "slotSubscribe"
val secondAttempt = WsSubscriptions.SubscribeData(
Mono.just(Tuples.of("sub-id-2", Flux.just("recovered".toByteArray()))),
"conn-1",
AtomicReference("sub-id-2"),
)
val firstAttempt = WsSubscriptions.SubscribeData(
Mono.just(Tuples.of("sub-id-1", Flux.never<ByteArray>())),
"conn-1",
AtomicReference("sub-id-1"),
)
val ws = mock<WsSubscriptions> {
on { subscribe(ChainRequest(topic, ListParams(param))) }
.doReturn(firstAttempt, secondAttempt)
on { unsubscribe(any()) } doReturn Mono.empty()
}
val genericSubscriptionConnect = GenericSubscriptionConnect(Chain.SOLANA__MAINNET, ws, topic, param, "slotUnsubscribe")
// GenericPersistentConnect wraps createConnection() with DurableFlux + SharedFluxHolder.
// The first subscription stalls (Flux.never) → timeout (85s) → TimeoutException →
// DurableFlux schedules retry → second subscription delivers "recovered".
StepVerifier.withVirtualTime { genericSubscriptionConnect.connect(io.emeraldpay.dshackle.upstream.Selector.empty) }
.expectSubscription()
.thenAwait(Duration.ofSeconds(90))
.expectNextMatches { it is ByteArray && String(it) == "recovered" }
.thenCancel()
.verify(Duration.ofSeconds(5))
// Verifies we re-subscribed (= sent slotSubscribe again on the same WS).
verify(ws, times(2)).subscribe(ChainRequest(topic, ListParams(param)))
}
} }