solution: Redis caching

This commit is contained in:
Igor Artamonov
2020-03-14 22:25:27 -04:00
parent 67ba1457fa
commit 35cb35fe0a
23 changed files with 529 additions and 83 deletions

View File

@@ -43,14 +43,13 @@ image::call-schema.png[alt="Call Schema",width=100%,align="center"]
== Roadmap == Roadmap
- [ ] Redis caching - [ ] JSON RPC emulation, in addition to gRPC protocol
- [ ] *Support Bitcoin RPC*
- [ ] Access to ERC-20 tokens on asset level
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] Prometheus monitoring - [ ] Prometheus monitoring
- [ ] BIP-32 Pubkey - [ ] BIP-32 Pubkey
- [ ] *Support Bitcoin RPC*
- [ ] Subscription to bitcoind notification over GRPC (instead of ZeroMQ)
- [ ] JSON RPC emulation, in addition to GRPC protocol
- [ ] Lightweight sidecar node connector - [ ] Lightweight sidecar node connector
- [ ] Access to ERC-20 tokens on asset level
- [ ] External logging - [ ] External logging
- [ ] Configurable upstream roles - [ ] Configurable upstream roles

View File

@@ -1,9 +1,67 @@
== Caching == Caching
=== In memory cache Dshackle can be configured to cache blockchain data. It can be a _hot_ in-memory cache, and optional _cold_ Redis-based
cache.
Dshackle has enough information to effectively cache data and evict outdated values. If some data has been removed from
the blockchain, for example when block was replaced with another block at the same height, then the old values are
immediately evicted from the caches.
=== In-memory cache
Dshackle keeps latest blocks in memory (by default 64 blocks) Dshackle keeps latest blocks in memory (by default 64 blocks)
=== Redis cache === Redis cache
TBD Dshackle can optionally cache blocks and transactions in Redis cache. The values are cached up to 1 hour, but
fresh blocks and transactions are cached for shorter period.
It makes sense to reuse the same Redis cache between multiple instances of the Dshackle.
.Basic config (dshackle.yaml)
[source, yaml]
----
cache:
redis:
enabled: true
----
.Full config (dshackle.yaml)
[source, yaml]
----
cache:
redis:
enabled: true
host: 127.0.0.1
port: 6379
db: 0
password: passw0rd!
----
.Options
|===
| Name | Default Value | Description
| enabled
| false
| Set to `true` if Dshackle should use Redis for caching
| host
| 127.0.0.1
| Redis host
| port
| 6379
| Redis port
| db
| 0
| Redis database
| password
| --
| Password if Redis requires authentication. The value can be read from Environment variable, to do that
specify it as `${REDIS_PASSWORD}`, where REDIS_PASSWORD is the name of the variable
|===

View File

@@ -15,7 +15,7 @@ springVersion=5.1.4.RELEASE
reactorVersion=3.2.9.RELEASE reactorVersion=3.2.9.RELEASE
# Our Libs # Our Libs
etherjarVersion=0.9.0-SNAPSHOT etherjarVersion=0.10.0-SNAPSHOT
# Testing # Testing
spockVersion=1.2-groovy-2.5 spockVersion=1.2-groovy-2.5

View File

@@ -19,25 +19,22 @@ import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import io.lettuce.core.AbstractRedisClient
import io.lettuce.core.cluster.RedisClusterClient
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.ApplicationContext
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import reactor.core.scheduler.Scheduler import reactor.core.scheduler.Scheduler
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.io.File import java.io.File
import java.lang.IllegalStateException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.system.exitProcess
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@@ -82,4 +79,9 @@ open class Config(
open fun fileResolver(): FileResolver { open fun fileResolver(): FileResolver {
return FileResolver(configDir()) return FileResolver(configDir())
} }
@Bean
open fun redisClient(): AbstractRedisClient {
return RedisClusterClient.create("redis://password@localhost:6379/0");
}
} }

View File

@@ -24,8 +24,9 @@ class BlocksRedisCache(
companion object { companion object {
private val log = LoggerFactory.getLogger(BlocksRedisCache::class.java) private val log = LoggerFactory.getLogger(BlocksRedisCache::class.java)
// max caching time is 24 hours private const val MAX_CACHE_TIME_MINUTES = 60L
private const val MAX_CACHE_TIME_HOURS = 24L // doesn't make sense to cached in redis short living objects
private const val MIN_CACHE_TIME_SECONDS = 10
} }
override fun read(key: BlockHash): Mono<BlockJson<TransactionRefJson>> { override fun read(key: BlockHash): Mono<BlockJson<TransactionRefJson>> {
@@ -37,23 +38,36 @@ class BlocksRedisCache(
} }
} }
fun evict(id: BlockHash): Mono<Void> {
return Mono.just(id)
.flatMap {
redis.del(key(it))
}
.then()
}
/** /**
* Add to cache. * Add to cache.
* Note that it returns Mono<Void> which must be subscribed to actually save * Note that it returns Mono<Void> which must be subscribed to actually save
*/ */
open fun add(block: BlockJson<TransactionRefJson>): Mono<Void> { fun add(block: BlockJson<TransactionRefJson>): Mono<Void> {
if (block.timestamp == null || block.hash == null) { if (block.timestamp == null || block.hash == null) {
return Mono.empty() return Mono.empty()
} }
return Mono.just(block) return Mono.just(block)
.flatMap { block -> .flatMap { block ->
val data = objectMapper.writeValueAsString(block) val data = objectMapper.writeValueAsString(block)
//default caching time is age of the block, i.e. block create hour ago //default caching time is age of the block, i.e. block create hour ago
//keep for hour, but block create 10 seconds ago cache for 10 seconds, as it //keep for hour, but block create 10 seconds ago cache for 10 seconds, as it
//still can be replaced in the blockchain //still can be replaced in the blockchain
val age = Instant.now().epochSecond - block.timestamp.epochSecond val age = Instant.now().epochSecond - block.timestamp.epochSecond
val ttl = min(age, TimeUnit.HOURS.toSeconds(MAX_CACHE_TIME_HOURS)) val ttl = min(age, TimeUnit.MINUTES.toSeconds(MAX_CACHE_TIME_MINUTES))
redis.setex(key(block.hash), ttl, data) if (ttl > MIN_CACHE_TIME_SECONDS) {
redis.setex(key(block.hash), ttl, data)
} else {
Mono.empty()
}
} }
.doOnError { .doOnError {
log.warn("Failed to save to Redis: ${it.message}") log.warn("Failed to save to Redis: ${it.message}")
@@ -68,7 +82,7 @@ class BlocksRedisCache(
/** /**
* Key in Redis * Key in Redis
*/ */
open fun key(hash: BlockHash): String { fun key(hash: BlockHash): String {
return "block:${chain.id}:${hash.toHex()}" return "block:${chain.id}:${hash.toHex()}"
} }
} }

View File

@@ -2,8 +2,10 @@ package io.emeraldpay.dshackle.cache
import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.reader.Reader
import io.infinitape.etherjar.domain.BlockHash import io.infinitape.etherjar.domain.BlockHash
import io.infinitape.etherjar.domain.TransactionId
import io.infinitape.etherjar.rpc.json.BlockJson import io.infinitape.etherjar.rpc.json.BlockJson
import io.infinitape.etherjar.rpc.json.TransactionJson import io.infinitape.etherjar.rpc.json.TransactionJson
import io.infinitape.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.BeanUtils import org.springframework.beans.BeanUtils
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -17,8 +19,8 @@ import reactor.core.publisher.Mono
* If any of the expected block transactions is not available it returns empty * If any of the expected block transactions is not available it returns empty
*/ */
class BlocksWithTxCache( class BlocksWithTxCache(
private val blocks: BlocksMemCache, private val blocks: Reader<BlockHash, BlockJson<TransactionRefJson>>,
private val txes: TxMemCache private val txes: Reader<TransactionId, TransactionJson>
): Reader<BlockHash, BlockJson<TransactionJson>> { ): Reader<BlockHash, BlockJson<TransactionJson>> {
companion object { companion object {

View File

@@ -1,5 +1,6 @@
package io.emeraldpay.dshackle.cache package io.emeraldpay.dshackle.cache
import io.emeraldpay.dshackle.reader.CompoundReader
import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.reader.Reader
import io.infinitape.etherjar.domain.BlockHash import io.infinitape.etherjar.domain.BlockHash
import io.infinitape.etherjar.domain.TransactionId import io.infinitape.etherjar.domain.TransactionId
@@ -7,11 +8,16 @@ import io.infinitape.etherjar.rpc.json.BlockJson
import io.infinitape.etherjar.rpc.json.TransactionJson import io.infinitape.etherjar.rpc.json.TransactionJson
import io.infinitape.etherjar.rpc.json.TransactionRefJson import io.infinitape.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.publisher.TopicProcessor
open class Caches( open class Caches(
private val blocksByHash: BlocksMemCache, private val memBlocksByHash: BlocksMemCache,
private val blocksByHeight: HeightCache, private val blocksByHeight: HeightCache,
private val txsByHash: TxMemCache private val memTxsByHash: TxMemCache,
private val redisBlocksByHash: BlocksRedisCache?,
private val redisTxsByHash: TxRedisCache?
) { ) {
companion object { companion object {
@@ -28,6 +34,22 @@ open class Caches(
} }
} }
private val blocksByHash: Reader<BlockHash, BlockJson<TransactionRefJson>>
private val txsByHash: Reader<TransactionId, TransactionJson>
init {
blocksByHash = if (redisBlocksByHash == null) {
memBlocksByHash
} else {
CompoundReader(memBlocksByHash, redisBlocksByHash)
}
txsByHash = if (redisTxsByHash == null) {
memTxsByHash
} else {
CompoundReader(memTxsByHash, redisTxsByHash)
}
}
/** /**
* Cache data that was just requested * Cache data that was just requested
*/ */
@@ -40,32 +62,51 @@ open class Caches(
} }
fun cache(tag: Tag, tx: TransactionJson) { fun cache(tag: Tag, tx: TransactionJson) {
txsByHash.add(tx) //do not cache transactions that are not in a block yet
if (tx.blockHash == null) {
return
}
memTxsByHash.add(tx)
memBlocksByHash.get(tx.blockHash)?.let { block ->
redisTxsByHash?.add(tx, block)
}
} }
fun cache(tag: Tag, block: BlockJson<TransactionRefJson>) { fun cache(tag: Tag, block: BlockJson<TransactionRefJson>) {
val job = ArrayList<Mono<Void>>()
if (tag == Tag.LATEST) { if (tag == Tag.LATEST) {
blocksByHash.add(block) //for LATEST data cache in memory, it will be short living so better to avoid Redis
memBlocksByHash.add(block)
val replaced = blocksByHeight.add(block) val replaced = blocksByHeight.add(block)
//evict cached transactions if an existing block was updated //evict cached transactions if an existing block was updated
replaced?.let { replacedBlockHash -> replaced?.let { replacedBlockHash ->
var evicted = false var evicted = false
blocksByHash.get(replacedBlockHash)?.let { block -> redisBlocksByHash?.evict(replacedBlockHash)
txsByHash.evict(block) memBlocksByHash.get(replacedBlockHash)?.let { block ->
memTxsByHash.evict(block)
redisTxsByHash?.evict(block)
evicted = true evicted = true
} }
if (!evicted) { if (!evicted) {
txsByHash.evict(replacedBlockHash) memTxsByHash.evict(replacedBlockHash)
} }
} }
} else if (tag == Tag.REQUESTED) { } else if (tag == Tag.REQUESTED) {
// if block with transactions was requests cache only transactions //shouldn't cache block json with transactions, separate txes and blocks with refs
block.transactions.forEach { tx -> val blockOnly = block.withoutTransactionDetails()
if (tx is TransactionJson) { memBlocksByHash.add(blockOnly)
cache(Tag.REQUESTED, tx) redisBlocksByHash?.add(blockOnly)?.let(job::add)
// now cache only transactions
val transactions = block.transactions.filterIsInstance<TransactionJson>()
if (transactions.isNotEmpty()) {
transactions.forEach { cache(Tag.REQUESTED, it) }
if (redisTxsByHash != null) {
job.add(Flux.fromIterable(transactions).flatMap { redisTxsByHash.add(it, block) }.then())
} }
} }
} }
Flux.fromIterable(job).flatMap { it }.subscribe() //TODO move out to a caller
} }
fun getBlocksByHash(): Reader<BlockHash, BlockJson<TransactionRefJson>> { fun getBlocksByHash(): Reader<BlockHash, BlockJson<TransactionRefJson>> {
@@ -107,12 +148,19 @@ open class Caches(
private var blocksByHash: BlocksMemCache? = null private var blocksByHash: BlocksMemCache? = null
private var blocksByHeight: HeightCache? = null private var blocksByHeight: HeightCache? = null
private var txsByHash: TxMemCache? = null private var txsByHash: TxMemCache? = null
private var redisBlocksByHash: BlocksRedisCache? = null
private var redisTxsByHash: TxRedisCache? = null
fun setBlockByHash(cache: BlocksMemCache): Builder { fun setBlockByHash(cache: BlocksMemCache): Builder {
blocksByHash = cache blocksByHash = cache
return this return this
} }
fun setBlockByHash(cache: BlocksRedisCache): Builder {
redisBlocksByHash = cache
return this
}
fun setBlockByHeight(cache: HeightCache): Builder { fun setBlockByHeight(cache: HeightCache): Builder {
blocksByHeight = cache blocksByHeight = cache
return this return this
@@ -123,6 +171,11 @@ open class Caches(
return this return this
} }
fun setTxByHash(cache: TxRedisCache): Builder {
redisTxsByHash = cache
return this
}
fun build(): Caches { fun build(): Caches {
if (blocksByHash == null) { if (blocksByHash == null) {
blocksByHash = BlocksMemCache() blocksByHash = BlocksMemCache()
@@ -133,7 +186,7 @@ open class Caches(
if (txsByHash == null) { if (txsByHash == null) {
txsByHash = TxMemCache() txsByHash = TxMemCache()
} }
return Caches(blocksByHash!!, blocksByHeight!!, txsByHash!!) return Caches(blocksByHash!!, blocksByHeight!!, txsByHash!!, redisBlocksByHash, redisTxsByHash)
} }
} }
} }

View File

@@ -0,0 +1,86 @@
package io.emeraldpay.dshackle.cache
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.config.EnvVariables
import io.emeraldpay.grpc.Chain
import io.lettuce.core.RedisClient
import io.lettuce.core.RedisURI
import io.lettuce.core.api.StatefulRedisConnection
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.env.Environment
import org.springframework.stereotype.Repository
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.annotation.PostConstruct
import kotlin.collections.HashMap
@Repository
class CachesFactory(
@Autowired private val objectMapper: ObjectMapper,
@Autowired private val env: Environment
) {
companion object {
private val log = LoggerFactory.getLogger(CachesFactory::class.java)
private const val CONFIG_PREFIX = "cache.redis"
}
private var redis: StatefulRedisConnection<String, String>? = null
private val all = EnumMap<Chain, Caches>(io.emeraldpay.grpc.Chain::class.java)
@PostConstruct
fun init() {
if (!env.getProperty("${CONFIG_PREFIX}.enabled", Boolean::class.java, false)) {
return
}
val address = env.getProperty("${CONFIG_PREFIX}.host", "127.0.0.1")
val port = env.getProperty("${CONFIG_PREFIX}.port", Int::class.java, 6379)
var uri = RedisURI.builder()
.withHost(address)
.withPort(port)
env.getProperty("${CONFIG_PREFIX}.db", Int::class.java)?.let { value ->
uri = uri.withDatabase(value)
}
//log URI _before_ adding a password, to avoid leaking it to the log
log.info("Use Redis cache at: ${uri.build().toURI()}")
env.getProperty("${CONFIG_PREFIX}.password")?.let { value ->
uri = uri.withPassword(value)
}
val client = RedisClient.create(uri.build())
val ping = client.connect().sync().ping()
if (ping != "PONG") {
throw IllegalStateException("Redis connection is not configured. Response: $ping")
}
redis = client.connect()
}
private fun initCache(chain: Chain): Caches {
val caches = Caches.newBuilder()
redis?.let { redis ->
caches.setBlockByHash(BlocksRedisCache(redis.reactive(), chain, objectMapper))
caches.setTxByHash(TxRedisCache(redis.reactive(), chain, objectMapper))
}
return caches.build()
}
fun getCaches(chain: Chain): Caches {
val existing = all[chain]
if (existing == null) {
synchronized(all) {
if (!all.containsKey(chain)) {
all[chain] = initCache(chain)
}
}
return getCaches(chain)
}
return existing
}
}

View File

@@ -39,7 +39,7 @@ class TxRedisCache(
} }
} }
open fun evict(block: BlockJson<TransactionRefJson>): Mono<Void> { fun evict(block: BlockJson<TransactionRefJson>): Mono<Void> {
return Mono.just(block) return Mono.just(block)
.map { block -> .map { block ->
block.transactions.map { block.transactions.map {
@@ -50,8 +50,15 @@ class TxRedisCache(
}.then() }.then()
} }
fun evict(id: TransactionId): Mono<Void> {
return Mono.just(id)
.flatMap {
redis.del(key(it))
}
.then()
}
open fun add(tx: TransactionJson, block: BlockJson<TransactionRefJson>): Mono<Void> { fun add(tx: TransactionJson, block: BlockJson<TransactionRefJson>): Mono<Void> {
if (tx.blockHash == null || block.hash == null || tx.blockHash != block.hash || block.timestamp == null) { if (tx.blockHash == null || block.hash == null || tx.blockHash != block.hash || block.timestamp == null) {
return Mono.empty() return Mono.empty()
} }
@@ -78,7 +85,7 @@ class TxRedisCache(
/** /**
* Key in Redis * Key in Redis
*/ */
open fun key(hash: TransactionId): String { fun key(hash: TransactionId): String {
return "tx:${chain.id}:${hash.toHex()}" return "tx:${chain.id}:${hash.toHex()}"
} }
} }

View File

@@ -0,0 +1,19 @@
package io.emeraldpay.dshackle.config
/**
* Update configuration value from environment variables. Format: ${ENV_VAR_NAME}
*/
class EnvVariables {
companion object {
private val envRegex = Regex("\\$\\{(\\w+?)}")
}
fun postProcess(value: String): String {
return envRegex.replace(value) { m ->
m.groups[1]?.let { g ->
System.getProperty(g.value) ?: System.getenv(g.value) ?: ""
} ?: ""
}
}
}

View File

@@ -32,7 +32,7 @@ import java.time.Duration
class UpstreamsConfigReader { class UpstreamsConfigReader {
private val log = LoggerFactory.getLogger(UpstreamsConfigReader::class.java) private val log = LoggerFactory.getLogger(UpstreamsConfigReader::class.java)
private val envRegex = Regex("\\$\\{(\\w+?)}") private val envVariables = EnvVariables()
fun read(input: InputStream): UpstreamsConfig { fun read(input: InputStream): UpstreamsConfig {
val yaml = Yaml() val yaml = Yaml()
@@ -268,13 +268,13 @@ class UpstreamsConfigReader {
private fun getListOfString(mappingNode: MappingNode?, key: String): List<String>? { private fun getListOfString(mappingNode: MappingNode?, key: String): List<String>? {
return getList<ScalarNode>(mappingNode, key)?.value return getList<ScalarNode>(mappingNode, key)?.value
?.map { it.value } ?.map { it.value }
?.map(this::postProcess) ?.map(envVariables::postProcess)
} }
private fun getValueAsString(mappingNode: MappingNode?, key: String): String? { private fun getValueAsString(mappingNode: MappingNode?, key: String): String? {
return getValue(mappingNode, key)?.let { return getValue(mappingNode, key)?.let {
return@let it.value return@let it.value
}?.let(this::postProcess) }?.let(envVariables::postProcess)
} }
private fun getValueAsInt(mappingNode: MappingNode?, key: String): Int? { private fun getValueAsInt(mappingNode: MappingNode?, key: String): Int? {
@@ -305,11 +305,4 @@ class UpstreamsConfigReader {
} }
} }
fun postProcess(value: String): String {
return envRegex.replace(value) { m ->
m.groups[1]?.let { g ->
System.getProperty(g.value) ?: System.getenv(g.value) ?: ""
} ?: ""
}
}
} }

View File

@@ -15,24 +15,22 @@
*/ */
package io.emeraldpay.dshackle.reader package io.emeraldpay.dshackle.reader
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
/**
* Composition of multiple readers. Reader returns first value returned by any of the source readers.
*/
class CompoundReader<K, D>( class CompoundReader<K, D>(
private val readers: Collection<Reader<K, D>> private vararg val readers: Reader<K, D>
): Reader<K, D> { ): Reader<K, D> {
override fun read(key: K): Mono<D> { override fun read(key: K): Mono<D> {
if (readers.isEmpty()) { if (readers.isEmpty()) {
return Mono.empty() return Mono.empty()
} }
var result = readers.first().read(key) return Flux.fromIterable(readers.asIterable())
if (readers.size == 1) { .flatMap { it.read(key) }.next()
return result
}
readers.stream().skip(1).forEach {
result = result.switchIfEmpty(it.read(key))
}
return result
} }
} }

View File

@@ -38,6 +38,9 @@ open class CachingEthereumApi(
companion object { companion object {
private val log = LoggerFactory.getLogger(CachingEthereumApi::class.java) private val log = LoggerFactory.getLogger(CachingEthereumApi::class.java)
/**
* Create caching API with empty memory-only cache
*/
@JvmStatic @JvmStatic
fun empty(): CachingEthereumApi { fun empty(): CachingEthereumApi {
return CachingEthereumApi(ObjectMapper(), Caches.default(), EmptyEthereumHead()) return CachingEthereumApi(ObjectMapper(), Caches.default(), EmptyEthereumHead())

View File

@@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.upstream
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.cache.CachesEnabled import io.emeraldpay.dshackle.cache.CachesEnabled
import io.emeraldpay.dshackle.cache.CachesFactory
import io.emeraldpay.dshackle.startup.UpstreamChange import io.emeraldpay.dshackle.startup.UpstreamChange
import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.calls.QuorumBasedMethods import io.emeraldpay.dshackle.upstream.calls.QuorumBasedMethods
@@ -35,7 +36,8 @@ import kotlin.concurrent.withLock
@Repository @Repository
class CurrentUpstreams( class CurrentUpstreams(
@Autowired private val objectMapper: ObjectMapper @Autowired private val objectMapper: ObjectMapper,
@Autowired private val cachesFactory: CachesFactory
): Upstreams { ): Upstreams {
private val log = LoggerFactory.getLogger(CurrentUpstreams::class.java) private val log = LoggerFactory.getLogger(CurrentUpstreams::class.java)
@@ -55,7 +57,7 @@ class CurrentUpstreams(
log.info("Upstream ${change.upstream.getId()} with chain $chain has been removed") log.info("Upstream ${change.upstream.getId()} with chain $chain has been removed")
} else { } else {
if (current == null) { if (current == null) {
val created = ChainUpstreams(chain, ArrayList<Upstream>(), Caches.default(), objectMapper) val created = ChainUpstreams(chain, ArrayList<Upstream>(), cachesFactory.getCaches(chain), objectMapper)
if (up is CachesEnabled) { if (up is CachesEnabled) {
up.setCaches(created.caches) up.setCaches(created.caches)
} }

View File

@@ -101,7 +101,12 @@ open class DirectEthereumApi(
return rpcClient.execute(callMapping(method, params)) return rpcClient.execute(callMapping(method, params))
.timeout(timeout, Mono.error(RpcException(-32603, "Upstream timeout"))) .timeout(timeout, Mono.error(RpcException(-32603, "Upstream timeout")))
.doOnNext { value -> .doOnNext { value ->
caches?.cacheRequested(value) try {
caches?.cacheRequested(value)
} catch (e: Throwable) {
//ignore all caching errors, client shouldn't have problems because of them
log.warn("Uncaught caching exception", e)
}
} }
} }

View File

@@ -52,4 +52,56 @@ class BlocksRedisCacheSpec extends Specification {
act == block act == block
} }
def "Evict existing block"() {
setup:
def cache = new BlocksRedisCache(
redis.reactive(), Chain.ETHEREUM, TestingCommons.objectMapper()
)
def block = new BlockJson<TransactionRefJson>()
block.number = 100
block.timestamp = Instant.now().minusSeconds(100).truncatedTo(ChronoUnit.SECONDS)
block.hash = BlockHash.from(hash2)
block.transactions = []
block.uncles = []
when:
cache.add(block).subscribe()
def act = cache.read(BlockHash.from(hash2)).block()
then:
act == block
when:
cache.evict(block.hash).subscribe()
act = cache.read(BlockHash.from(hash2)).block()
then:
act == null
}
def "Evict non-existing block"() {
setup:
def cache = new BlocksRedisCache(
redis.reactive(), Chain.ETHEREUM, TestingCommons.objectMapper()
)
def block = new BlockJson<TransactionRefJson>()
block.number = 100
block.timestamp = Instant.now().minusSeconds(100).truncatedTo(ChronoUnit.SECONDS)
block.hash = BlockHash.from(hash2)
block.transactions = []
block.uncles = []
when:
cache.add(block).subscribe()
def act = cache.read(BlockHash.from(hash2)).block()
then:
act == block
when:
cache.evict(BlockHash.from(hash3)).subscribe()
act = cache.read(BlockHash.from(hash2)).block()
then:
act == block
}
} }

View File

@@ -54,6 +54,35 @@ class TxRedisCacheSpec extends Specification {
act == tx act == tx
} }
def "Evict single tx"() {
setup:
def block = new BlockJson<TransactionRefJson>()
block.number = 100
block.timestamp = Instant.now().minusSeconds(100).truncatedTo(ChronoUnit.SECONDS)
block.hash = BlockHash.from(hash2)
block.transactions = []
block.uncles = []
def tx = new TransactionJson()
tx.hash = TransactionId.from(hash3)
tx.blockHash = block.hash
tx.blockNumber = block.number
tx.value = Wei.ofEthers(1.234)
tx.nonce = 0
when:
cache.add(tx, block).subscribe()
def act = cache.read(tx.hash).block()
then:
act == tx
when:
cache.evict(tx.hash).subscribe()
act = cache.read(tx.hash).block()
then:
act == null
}
def "Evict all by block data"() { def "Evict all by block data"() {
when: when:
def block1 = new BlockJson<TransactionRefJson>() def block1 = new BlockJson<TransactionRefJson>()

View File

@@ -0,0 +1,29 @@
package io.emeraldpay.dshackle.config
import spock.lang.Specification
class EnvVariablesSpec extends Specification {
EnvVariables reader = new EnvVariables()
def "Post process for usual strings"() {
expect:
s == reader.postProcess(s)
where:
s << ["", "a", "13143", "/etc/client1.myservice.com.key", "true", "1a68f20154fc258fe4149c199ad8f281"]
}
def "Post process replaces from env"() {
setup:
System.setProperty("id", "1")
System.setProperty("HOME", "/home/user")
System.setProperty("PASSWORD", "1a68f20154fc258fe4149c199ad8f281")
expect:
replaced == reader.postProcess(orig)
where:
orig | replaced
"p_\${id}" | "p_1"
"home: \${HOME}" | "home: /home/user"
"\${PASSWORD}" | "1a68f20154fc258fe4149c199ad8f281"
}
}

View File

@@ -134,27 +134,6 @@ class UpstreamsConfigReaderSpec extends Specification {
} }
} }
def "Post process for usual strings"() {
expect:
s == reader.postProcess(s)
where:
s << ["", "a", "13143", "/etc/client1.myservice.com.key", "true", "1a68f20154fc258fe4149c199ad8f281"]
}
def "Post process replaces from env"() {
setup:
System.setProperty("id", "1")
System.setProperty("HOME", "/home/user")
System.setProperty("PASSWORD", "1a68f20154fc258fe4149c199ad8f281")
expect:
replaced == reader.postProcess(orig)
where:
orig | replaced
"p_\${id}" | "p_1"
"home: \${HOME}" | "home: /home/user"
"\${PASSWORD}" | "1a68f20154fc258fe4149c199ad8f281"
}
def "Parse config without defaults"() { def "Parse config without defaults"() {
setup: setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-no-defaults.yaml") def config = this.class.getClassLoader().getResourceAsStream("upstreams-no-defaults.yaml")

View File

@@ -0,0 +1,95 @@
package io.emeraldpay.dshackle.reader
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
import spock.lang.Specification
import java.time.Duration
class CompoundReaderSpec extends Specification {
def reader1 = new Reader<String, String>() {
@Override
Mono<String> read(String key) {
return Mono.just("test-1").delaySubscription(Duration.ofMillis(100))
}
}
def reader2 = new Reader<String, String>() {
@Override
Mono<String> read(String key) {
return Mono.just("test-2").delaySubscription(Duration.ofMillis(200))
}
}
def reader3 = new Reader<String, String>() {
@Override
Mono<String> read(String key) {
return Mono.just("test-3").delaySubscription(Duration.ofMillis(300))
}
}
def reader1Empty = new Reader<String, String>() {
@Override
Mono<String> read(String key) {
return Mono.<String>empty().delaySubscription(Duration.ofMillis(100))
}
}
def "Return empty when no readers"() {
setup:
def reader = new CompoundReader<String, String>()
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Return first"() {
setup:
def reader = new CompoundReader<String, String>(reader1, reader2, reader3)
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectNext("test-1")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Return second"() {
setup:
def reader = new CompoundReader<String, String>(reader3, reader2)
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectNext("test-2")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Return third"() {
setup:
def reader = new CompoundReader<String, String>(reader3, reader2, reader1)
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectNext("test-1")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Ignore empty"() {
setup:
def reader = new CompoundReader<String, String>(reader3, reader1Empty, reader2, reader1Empty)
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectNext("test-2")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
}

View File

@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.cache.CachesFactory
import io.emeraldpay.dshackle.upstream.AggregatedUpstream import io.emeraldpay.dshackle.upstream.AggregatedUpstream
import io.emeraldpay.dshackle.upstream.ChainUpstreams import io.emeraldpay.dshackle.upstream.ChainUpstreams
import io.emeraldpay.dshackle.upstream.calls.DirectCallMethods import io.emeraldpay.dshackle.upstream.calls.DirectCallMethods
@@ -28,6 +29,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.EthereumUpstream
import io.emeraldpay.grpc.Chain import io.emeraldpay.grpc.Chain
import io.infinitape.etherjar.rpc.JacksonRpcConverter import io.infinitape.etherjar.rpc.JacksonRpcConverter
import io.infinitape.etherjar.rpc.ReactorRpcClient import io.infinitape.etherjar.rpc.ReactorRpcClient
import org.springframework.core.env.StandardEnvironment
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -73,4 +75,8 @@ class TestingCommons {
static AggregatedUpstream aggregatedUpstream(EthereumUpstream up) { static AggregatedUpstream aggregatedUpstream(EthereumUpstream up) {
return new ChainUpstreams(Chain.ETHEREUM, [up], Caches.default(), objectMapper()) return new ChainUpstreams(Chain.ETHEREUM, [up], Caches.default(), objectMapper())
} }
static CachesFactory emptyCaches() {
return new CachesFactory(objectMapper(), new StandardEnvironment())
}
} }

View File

@@ -11,7 +11,7 @@ class CurrentUpstreamsSpec extends Specification {
def "add upstream"() { def "add upstream"() {
setup: setup:
def current = new CurrentUpstreams(TestingCommons.objectMapper()) def current = new CurrentUpstreams(TestingCommons.objectMapper(), TestingCommons.emptyCaches())
def up = new EthereumUpstreamMock("test", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up = new EthereumUpstreamMock("test", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
when: when:
current.update(new UpstreamChange(Chain.ETHEREUM, up, UpstreamChange.ChangeType.ADDED)) current.update(new UpstreamChange(Chain.ETHEREUM, up, UpstreamChange.ChangeType.ADDED))
@@ -22,7 +22,7 @@ class CurrentUpstreamsSpec extends Specification {
def "add multiple upstreams"() { def "add multiple upstreams"() {
setup: setup:
def current = new CurrentUpstreams(TestingCommons.objectMapper()) def current = new CurrentUpstreams(TestingCommons.objectMapper(), TestingCommons.emptyCaches())
def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
def up2 = new EthereumUpstreamMock("test2", Chain.ETHEREUM_CLASSIC, TestingCommons.api(Stub(ReactorRpcClient))) def up2 = new EthereumUpstreamMock("test2", Chain.ETHEREUM_CLASSIC, TestingCommons.api(Stub(ReactorRpcClient)))
def up3 = new EthereumUpstreamMock("test3", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up3 = new EthereumUpstreamMock("test3", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
@@ -38,7 +38,7 @@ class CurrentUpstreamsSpec extends Specification {
def "remove upstream"() { def "remove upstream"() {
setup: setup:
def current = new CurrentUpstreams(TestingCommons.objectMapper()) def current = new CurrentUpstreams(TestingCommons.objectMapper(), TestingCommons.emptyCaches())
def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
def up2 = new EthereumUpstreamMock("test2", Chain.ETHEREUM_CLASSIC, TestingCommons.api(Stub(ReactorRpcClient))) def up2 = new EthereumUpstreamMock("test2", Chain.ETHEREUM_CLASSIC, TestingCommons.api(Stub(ReactorRpcClient)))
def up3 = new EthereumUpstreamMock("test3", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up3 = new EthereumUpstreamMock("test3", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
@@ -56,7 +56,7 @@ class CurrentUpstreamsSpec extends Specification {
def "available after adding"() { def "available after adding"() {
setup: setup:
def current = new CurrentUpstreams(TestingCommons.objectMapper()) def current = new CurrentUpstreams(TestingCommons.objectMapper(), TestingCommons.emptyCaches())
def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient))) def up1 = new EthereumUpstreamMock("test1", Chain.ETHEREUM, TestingCommons.api(Stub(ReactorRpcClient)))
when: when:

View File

@@ -0,0 +1,15 @@
version: v1
defaultOptions:
- chains:
- bitcoin
options:
min-peers: 3
upstreams:
- id: local
chain: bitcoin
connection:
bitcoin:
rpc:
url: "http://localhost:8545"