Subscription optimization (#709)

* share logs producer to reuse logs subscriptions

* support unsubscription from proxied subscriptions
This commit is contained in:
Vyacheslav
2025-08-22 17:37:09 +03:00
committed by GitHub
parent bf935cf527
commit 59295cf9e0
14 changed files with 659 additions and 331 deletions

View File

@@ -94,7 +94,7 @@ open class NativeSubscribe(
objectMapper.readValue(it.newInput(), List::class.java)
}
}
subscribe(chain, method, params, matcher, subscriptionId)
subscribe(chain, method, params, matcher, subscriptionId, request.unsubscribeMethod)
}
return publisher.map { ResponseHolder(it, nonce) }
}
@@ -119,11 +119,11 @@ open class NativeSubscribe(
}
}
open fun subscribe(chain: Chain, method: String, params: Any?, matcher: Selector.Matcher): Flux<out Any> =
subscribe(chain, method, params, matcher, "")
subscribe(chain, method, params, matcher, "", "")
open fun subscribe(chain: Chain, method: String, params: Any?, matcher: Selector.Matcher, subscriptionId: String): Flux<out Any> =
open fun subscribe(chain: Chain, method: String, params: Any?, matcher: Selector.Matcher, subscriptionId: String, unsubscribeMethod: String): Flux<out Any> =
getUpstream(chain).getEgressSubscription()
.subscribe(method, params, matcher)
.subscribe(method, params, matcher, unsubscribeMethod)
.doOnError {
log.error("sub_id:$subscriptionId Error during subscription to $method, chain $chain, params $params", it)
}

View File

@@ -19,7 +19,7 @@ import reactor.core.publisher.Flux
interface EgressSubscription {
fun getAvailableTopics(): List<String>
fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher): Flux<out Any>
fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher, unsubscribeMethod: String): Flux<out Any>
}
object EmptyEgressSubscription : EgressSubscription {
@@ -27,7 +27,7 @@ object EmptyEgressSubscription : EgressSubscription {
return emptyList()
}
override fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher): Flux<out Any> {
override fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher, unsubscribeMethod: String): Flux<out Any> {
return Flux.empty()
}
}

View File

@@ -21,5 +21,5 @@ package io.emeraldpay.dshackle.upstream
interface IngressSubscription {
fun getAvailableTopics(): List<String>
fun <T> get(topic: String, params: Any?): SubscriptionConnect<T>?
fun <T> get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect<T>?
}

View File

@@ -25,7 +25,7 @@ open class NoIngressSubscription : IngressSubscription {
return listOf()
}
override fun <T> get(topic: String, params: Any?): SubscriptionConnect<T>? {
override fun <T> get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect<T>? {
return null
}
}

View File

@@ -80,7 +80,7 @@ open class EthereumEgressSubscription(
}
@Suppress("UNCHECKED_CAST")
override fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher): Flux<out Any> {
override fun subscribe(topic: String, params: Any?, matcher: Selector.Matcher, unsubscribeMethod: String): Flux<out Any> {
if (topic == METHOD_NEW_HEADS) {
return newHeads.connect(matcher)
}

View File

@@ -20,63 +20,20 @@ import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.SubscriptionConnect
import io.emeraldpay.dshackle.upstream.ethereum.domain.Address
import io.emeraldpay.dshackle.upstream.ethereum.hex.Hex32
import io.emeraldpay.dshackle.upstream.ethereum.hex.HexDataComparator
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.LogMessage
import reactor.core.publisher.Flux
import reactor.core.scheduler.Scheduler
import java.util.function.Function
open class ConnectLogs(
upstream: Multistream,
private val connectBlockUpdates: ConnectBlockUpdates,
scheduler: Scheduler,
) {
companion object {
private val ADDR_COMPARATOR = HexDataComparator()
private val TOPIC_COMPARATOR = HexDataComparator()
}
constructor(upstream: Multistream, scheduler: Scheduler) : this(upstream, ConnectBlockUpdates(upstream, scheduler))
private val produceLogs = ProduceLogs(upstream)
fun start(matcher: Selector.Matcher): Flux<LogMessage> {
return produceLogs.produce(connectBlockUpdates.connect(matcher))
}
private val sharedLogsProducer = SharedLogsProducer(upstream, scheduler)
open fun create(addresses: List<Address>, topics: List<List<Hex32>?>): SubscriptionConnect<LogMessage> {
return object : SubscriptionConnect<LogMessage> {
override fun connect(matcher: Selector.Matcher): Flux<LogMessage> {
if (addresses.isEmpty() && topics.isEmpty()) {
return start(matcher)
}
return start(matcher)
.transform(filtered(addresses, topics))
}
}
}
fun filtered(addresses: List<Address>, selectedTopics: List<List<Hex32>?>): Function<Flux<LogMessage>, Flux<LogMessage>> {
val sortedAddresses: List<Address> = addresses.sortedWith(ADDR_COMPARATOR)
val topicSets: List<Set<Hex32>?> = selectedTopics.map { topicsOrNull ->
topicsOrNull?.toSet()
}
return Function { logs ->
logs.filter { log ->
val goodAddress = sortedAddresses.isEmpty() ||
sortedAddresses.binarySearch(log.address, ADDR_COMPARATOR) >= 0
val goodTopics = if (topicSets.isEmpty()) {
true
} else if (log.topics.size < topicSets.size) {
false
} else {
topicSets.zip(log.topics).all { (wantedTopics, logTopic) ->
wantedTopics == null || logTopic in wantedTopics
}
}
goodAddress && goodTopics
return sharedLogsProducer.subscribe(addresses, topics, matcher)
}
}
}

View File

@@ -32,7 +32,7 @@ class EthereumWsIngressSubscription(
}
@Suppress("UNCHECKED_CAST")
override fun <T> get(topic: String, params: Any?): SubscriptionConnect<T>? {
override fun <T> get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect<T>? {
if (topic == EthereumEgressSubscription.METHOD_PENDING_TXES) {
return pendingTxes as SubscriptionConnect<T>
}

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) 2021 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.ethereum.subscribe
import io.emeraldpay.dshackle.upstream.Multistream
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.ethereum.domain.Address
import io.emeraldpay.dshackle.upstream.ethereum.hex.Hex32
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.LogMessage
import org.slf4j.LoggerFactory
import reactor.core.Disposable
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import reactor.core.scheduler.Scheduler
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
class SharedLogsProducer(
upstream: Multistream,
scheduler: Scheduler,
) {
companion object {
private val log = LoggerFactory.getLogger(SharedLogsProducer::class.java)
}
private val produceLogs = ProduceLogs(upstream)
private val connectBlockUpdates = ConnectBlockUpdates(upstream, scheduler)
private val subscriptions = ConcurrentHashMap<String, LogsSubscription>()
private val subscriptionCounter = AtomicInteger(0)
// Map of matcher hash to shared streams
private val sharedStreams = ConcurrentHashMap<String, Disposable>()
private val logsSinks = ConcurrentHashMap<String, Sinks.Many<LogMessage>>()
fun subscribe(
addresses: List<Address>,
topics: List<List<Hex32>?>,
matcher: Selector.Matcher,
): Flux<LogMessage> {
val subscriptionId = "logs-sub-${subscriptionCounter.incrementAndGet()}"
val subscription = LogsSubscription(subscriptionId, addresses, topics, matcher)
val matcherKey = matcher.describeInternal()
subscriptions[subscriptionId] = subscription
// Start shared stream if this is the first subscription for this matcher
val subscriptionsForMatcher = subscriptions.values.filter { it.matcher.describeInternal() == matcherKey }
if (subscriptionsForMatcher.size == 1) {
startSharedStream(matcher)
}
val logsFlux = logsSinks[matcherKey]?.asFlux() ?: Flux.empty()
return logsFlux
.filter { logMessage -> subscription.matches(logMessage) }
.doFinally { // Remove subscription when stream ends
subscriptions.remove(subscriptionId)
// Stop shared stream if no more subscriptions for this matcher
val remainingSubscriptionsForMatcher = subscriptions.values.filter { it.matcher.describeInternal() == matcherKey }
if (remainingSubscriptionsForMatcher.isEmpty()) {
stopSharedStream(matcherKey)
}
}
}
private fun startSharedStream(matcher: Selector.Matcher) {
val matcherKey = matcher.describeInternal()
val logsSink = Sinks.many().multicast().onBackpressureBuffer<LogMessage>()
logsSinks[matcherKey] = logsSink
val sharedStream = produceLogs.produce(connectBlockUpdates.connect(matcher))
.subscribe(
{ logMessage ->
logsSink.emitNext(logMessage) { _, res -> res == Sinks.EmitResult.FAIL_NON_SERIALIZED }
},
{ error ->
log.error("Error in shared logs stream for matcher: $matcherKey", error)
logsSink.emitError(error) { _, res -> res == Sinks.EmitResult.FAIL_NON_SERIALIZED }
},
{
logsSink.emitComplete { _, res -> res == Sinks.EmitResult.FAIL_NON_SERIALIZED }
},
)
sharedStreams[matcherKey] = sharedStream
}
private fun stopSharedStream(matcherKey: String) {
sharedStreams[matcherKey]?.dispose()
sharedStreams.remove(matcherKey)
logsSinks[matcherKey]?.tryEmitComplete()
logsSinks.remove(matcherKey)
}
private data class LogsSubscription(
val id: String,
val addresses: List<Address>,
val topics: List<List<Hex32>?>,
val matcher: Selector.Matcher,
) {
fun matches(logMessage: LogMessage): Boolean {
val addressMatch = addresses.isEmpty() || addresses.contains(logMessage.address)
val topicsMatch = if (topics.isEmpty()) {
true
} else if (logMessage.topics.size < topics.size) {
false
} else {
topics.zip(logMessage.topics).all { (wantedTopics, logTopic) ->
wantedTopics == null || logTopic in wantedTopics
}
}
return addressMatch && topicsMatch
}
}
}

View File

@@ -22,17 +22,17 @@ class GenericEgressSubscription(
.distinct()
}
override fun subscribe(topic: String, params: Any?, matcher: Matcher): Flux<ByteArray> {
override fun subscribe(topic: String, params: Any?, matcher: Matcher, unsubscribeMethod: String): Flux<ByteArray> {
val up = multistream.getUpstreams()
.filter { it.isAvailable() }
.shuffled()
.first { matcher.matches(it) } as GenericUpstream
val result = up.getIngressSubscription().get<ByteArray>(topic, params)?.connect(matcher)
val result = up.getIngressSubscription().get<ByteArray>(topic, params, unsubscribeMethod)?.connect(matcher)
if (result == null) {
log.warn("subscription source not found for topic {}", topic)
return Flux.empty()
}
return up.getIngressSubscription().get<ByteArray>(topic, params)?.connect(matcher) ?: Flux.empty()
return up.getIngressSubscription().get<ByteArray>(topic, params, unsubscribeMethod)?.connect(matcher) ?: Flux.empty()
}
}

View File

@@ -21,12 +21,13 @@ class GenericIngressSubscription(val conn: WsSubscriptions, val methods: List<St
private val holders = ConcurrentHashMap<Pair<String, Any?>, SubscriptionConnect<out Any>>()
@Suppress("UNCHECKED_CAST")
override fun <T> get(topic: String, params: Any?): SubscriptionConnect<T> {
override fun <T> get(topic: String, params: Any?, unsubscribeMethod: String): SubscriptionConnect<T> {
return holders.computeIfAbsent(topic to params) { key ->
GenericSubscriptionConnect(
conn,
key.first,
key.second,
unsubscribeMethod,
)
} as SubscriptionConnect<T>
}
@@ -36,6 +37,7 @@ class GenericSubscriptionConnect(
val conn: WsSubscriptions,
val topic: String,
val params: Any?,
val unsubscribeMethod: String,
) : GenericPersistentConnect() {
companion object {
@@ -44,8 +46,8 @@ class GenericSubscriptionConnect(
@Suppress("UNCHECKED_CAST")
override fun createConnection(): Flux<Any> {
return conn.subscribe(ChainRequest(topic, ListParams(getParams(params) as List<Any>)))
.data
val sub = conn.subscribe(ChainRequest(topic, ListParams(getParams(params) as List<Any>)))
return sub.data
.timeout(
Duration.ofSeconds(85),
Mono.empty<ByteArray?>().doOnEach {
@@ -55,6 +57,12 @@ class GenericSubscriptionConnect(
.onErrorResume {
log.error("Error during subscription to $topic", it)
Mono.empty()
}.doFinally {
if (unsubscribeMethod != "") {
conn.unsubscribe(ChainRequest(unsubscribeMethod, ListParams(sub.subId.get()))).subscribe {
log.info("unsubscribed from ${sub.subId.get()}")
}
}
} as Flux<Any>
}