renamed project to "Remote Cache Build Server" (RBCS)
This commit is contained in:
17
rbcs-client/build.gradle
Normal file
17
rbcs-client/build.gradle
Normal file
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
alias catalog.plugins.kotlin.jvm
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':rbcs-api')
|
||||
implementation project(':rbcs-common')
|
||||
implementation catalog.picocli
|
||||
implementation catalog.slf4j.api
|
||||
implementation catalog.netty.buffer
|
||||
implementation catalog.netty.codec.http
|
||||
|
||||
testRuntimeOnly catalog.logback.classic
|
||||
}
|
||||
|
||||
|
17
rbcs-client/src/main/java/module-info.java
Normal file
17
rbcs-client/src/main/java/module-info.java
Normal file
@@ -0,0 +1,17 @@
|
||||
module net.woggioni.rbcs.client {
|
||||
requires io.netty.handler;
|
||||
requires io.netty.codec.http;
|
||||
requires io.netty.transport;
|
||||
requires kotlin.stdlib;
|
||||
requires io.netty.common;
|
||||
requires io.netty.buffer;
|
||||
requires java.xml;
|
||||
requires net.woggioni.rbcs.common;
|
||||
requires net.woggioni.rbcs.api;
|
||||
requires io.netty.codec;
|
||||
requires org.slf4j;
|
||||
|
||||
exports net.woggioni.rbcs.client;
|
||||
|
||||
opens net.woggioni.rbcs.client.schema;
|
||||
}
|
344
rbcs-client/src/main/kotlin/net/woggioni/rbcs/client/Client.kt
Normal file
344
rbcs-client/src/main/kotlin/net/woggioni/rbcs/client/Client.kt
Normal file
@@ -0,0 +1,344 @@
|
||||
package net.woggioni.rbcs.client
|
||||
|
||||
import io.netty.bootstrap.Bootstrap
|
||||
import io.netty.buffer.ByteBuf
|
||||
import io.netty.buffer.Unpooled
|
||||
import io.netty.channel.Channel
|
||||
import io.netty.channel.ChannelHandlerContext
|
||||
import io.netty.channel.ChannelOption
|
||||
import io.netty.channel.ChannelPipeline
|
||||
import io.netty.channel.SimpleChannelInboundHandler
|
||||
import io.netty.channel.nio.NioEventLoopGroup
|
||||
import io.netty.channel.pool.AbstractChannelPoolHandler
|
||||
import io.netty.channel.pool.ChannelPool
|
||||
import io.netty.channel.pool.FixedChannelPool
|
||||
import io.netty.channel.socket.nio.NioSocketChannel
|
||||
import io.netty.handler.codec.DecoderException
|
||||
import io.netty.handler.codec.http.DefaultFullHttpRequest
|
||||
import io.netty.handler.codec.http.FullHttpRequest
|
||||
import io.netty.handler.codec.http.FullHttpResponse
|
||||
import io.netty.handler.codec.http.HttpClientCodec
|
||||
import io.netty.handler.codec.http.HttpContentDecompressor
|
||||
import io.netty.handler.codec.http.HttpHeaderNames
|
||||
import io.netty.handler.codec.http.HttpHeaderValues
|
||||
import io.netty.handler.codec.http.HttpMethod
|
||||
import io.netty.handler.codec.http.HttpObjectAggregator
|
||||
import io.netty.handler.codec.http.HttpResponseStatus
|
||||
import io.netty.handler.codec.http.HttpVersion
|
||||
import io.netty.handler.ssl.SslContext
|
||||
import io.netty.handler.ssl.SslContextBuilder
|
||||
import io.netty.handler.stream.ChunkedWriteHandler
|
||||
import io.netty.util.concurrent.Future
|
||||
import io.netty.util.concurrent.GenericFutureListener
|
||||
import net.woggioni.rbcs.client.impl.Parser
|
||||
import net.woggioni.rbcs.common.Xml
|
||||
import net.woggioni.rbcs.common.contextLogger
|
||||
import net.woggioni.rbcs.common.debug
|
||||
import net.woggioni.rbcs.common.trace
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.time.Duration
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import io.netty.util.concurrent.Future as NettyFuture
|
||||
|
||||
|
||||
class RemoteBuildCacheClient(private val profile: Configuration.Profile) : AutoCloseable {
|
||||
private val group: NioEventLoopGroup
|
||||
private var sslContext: SslContext
|
||||
private val log = contextLogger()
|
||||
private val pool: ChannelPool
|
||||
|
||||
data class Configuration(
|
||||
val profiles: Map<String, Profile>
|
||||
) {
|
||||
sealed class Authentication {
|
||||
data class TlsClientAuthenticationCredentials(
|
||||
val key: PrivateKey,
|
||||
val certificateChain: Array<X509Certificate>
|
||||
) : Authentication()
|
||||
|
||||
data class BasicAuthenticationCredentials(val username: String, val password: String) : Authentication()
|
||||
}
|
||||
|
||||
class RetryPolicy(
|
||||
val maxAttempts: Int,
|
||||
val initialDelayMillis: Long,
|
||||
val exp: Double
|
||||
)
|
||||
|
||||
data class Profile(
|
||||
val serverURI: URI,
|
||||
val authentication: Authentication?,
|
||||
val connectionTimeout: Duration?,
|
||||
val maxConnections: Int,
|
||||
val retryPolicy: RetryPolicy?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun parse(path: Path): Configuration {
|
||||
return Files.newInputStream(path).use {
|
||||
Xml.parseXml(path.toUri().toURL(), it)
|
||||
}.let(Parser::parse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
group = NioEventLoopGroup()
|
||||
sslContext = SslContextBuilder.forClient().also { builder ->
|
||||
(profile.authentication as? Configuration.Authentication.TlsClientAuthenticationCredentials)?.let { tlsClientAuthenticationCredentials ->
|
||||
builder.keyManager(
|
||||
tlsClientAuthenticationCredentials.key,
|
||||
*tlsClientAuthenticationCredentials.certificateChain
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
|
||||
val (scheme, host, port) = profile.serverURI.run {
|
||||
Triple(
|
||||
if (scheme == null) "http" else profile.serverURI.scheme,
|
||||
host,
|
||||
port.takeIf { it > 0 } ?: if ("https" == scheme.lowercase()) 443 else 80
|
||||
)
|
||||
}
|
||||
|
||||
val bootstrap = Bootstrap().apply {
|
||||
group(group)
|
||||
channel(NioSocketChannel::class.java)
|
||||
option(ChannelOption.TCP_NODELAY, true)
|
||||
option(ChannelOption.SO_KEEPALIVE, true)
|
||||
remoteAddress(InetSocketAddress(host, port))
|
||||
profile.connectionTimeout?.let {
|
||||
option(ChannelOption.CONNECT_TIMEOUT_MILLIS, it.toMillis().toInt())
|
||||
}
|
||||
}
|
||||
val channelPoolHandler = object : AbstractChannelPoolHandler() {
|
||||
|
||||
@Volatile
|
||||
private var connectionCount = AtomicInteger()
|
||||
|
||||
@Volatile
|
||||
private var leaseCount = AtomicInteger()
|
||||
|
||||
override fun channelReleased(ch: Channel) {
|
||||
val activeLeases = leaseCount.decrementAndGet()
|
||||
log.trace {
|
||||
"Released channel ${ch.id().asShortText()}, number of active leases: $activeLeases"
|
||||
}
|
||||
}
|
||||
|
||||
override fun channelAcquired(ch: Channel) {
|
||||
val activeLeases = leaseCount.getAndIncrement()
|
||||
log.trace {
|
||||
"Acquired channel ${ch.id().asShortText()}, number of active leases: $activeLeases"
|
||||
}
|
||||
}
|
||||
|
||||
override fun channelCreated(ch: Channel) {
|
||||
val connectionId = connectionCount.getAndIncrement()
|
||||
log.debug {
|
||||
"Created connection $connectionId, total number of active connections: $connectionId"
|
||||
}
|
||||
ch.closeFuture().addListener {
|
||||
val activeConnections = connectionCount.decrementAndGet()
|
||||
log.debug {
|
||||
"Closed connection $connectionId, total number of active connections: $activeConnections"
|
||||
}
|
||||
}
|
||||
val pipeline: ChannelPipeline = ch.pipeline()
|
||||
|
||||
// Add SSL handler if needed
|
||||
if ("https".equals(scheme, ignoreCase = true)) {
|
||||
pipeline.addLast("ssl", sslContext.newHandler(ch.alloc(), host, port))
|
||||
}
|
||||
|
||||
// HTTP handlers
|
||||
pipeline.addLast("codec", HttpClientCodec())
|
||||
pipeline.addLast("decompressor", HttpContentDecompressor())
|
||||
pipeline.addLast("aggregator", HttpObjectAggregator(134217728))
|
||||
pipeline.addLast("chunked", ChunkedWriteHandler())
|
||||
}
|
||||
}
|
||||
pool = FixedChannelPool(bootstrap, channelPoolHandler, profile.maxConnections)
|
||||
}
|
||||
|
||||
private fun executeWithRetry(operation: () -> CompletableFuture<FullHttpResponse>): CompletableFuture<FullHttpResponse> {
|
||||
val retryPolicy = profile.retryPolicy
|
||||
return if (retryPolicy != null) {
|
||||
val outcomeHandler = OutcomeHandler<FullHttpResponse> { outcome ->
|
||||
when (outcome) {
|
||||
is OperationOutcome.Success -> {
|
||||
val response = outcome.result
|
||||
val status = response.status()
|
||||
when (status) {
|
||||
HttpResponseStatus.TOO_MANY_REQUESTS -> {
|
||||
val retryAfter = response.headers()[HttpHeaderNames.RETRY_AFTER]?.let { headerValue ->
|
||||
try {
|
||||
headerValue.toLong() * 1000
|
||||
} catch (nfe: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
OutcomeHandlerResult.Retry(retryAfter)
|
||||
}
|
||||
|
||||
HttpResponseStatus.INTERNAL_SERVER_ERROR, HttpResponseStatus.SERVICE_UNAVAILABLE ->
|
||||
OutcomeHandlerResult.Retry()
|
||||
|
||||
else -> OutcomeHandlerResult.DoNotRetry()
|
||||
}
|
||||
}
|
||||
|
||||
is OperationOutcome.Failure -> {
|
||||
OutcomeHandlerResult.Retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
executeWithRetry(
|
||||
group,
|
||||
retryPolicy.maxAttempts,
|
||||
retryPolicy.initialDelayMillis.toDouble(),
|
||||
retryPolicy.exp,
|
||||
outcomeHandler,
|
||||
operation
|
||||
)
|
||||
} else {
|
||||
operation()
|
||||
}
|
||||
}
|
||||
|
||||
fun healthCheck(nonce: ByteArray): CompletableFuture<ByteArray?> {
|
||||
return executeWithRetry {
|
||||
sendRequest(profile.serverURI, HttpMethod.TRACE, nonce)
|
||||
}.thenApply {
|
||||
val status = it.status()
|
||||
if (it.status() != HttpResponseStatus.OK) {
|
||||
throw HttpException(status)
|
||||
} else {
|
||||
it.content()
|
||||
}
|
||||
}.thenApply { maybeByteBuf ->
|
||||
maybeByteBuf?.let {
|
||||
val result = ByteArray(it.readableBytes())
|
||||
it.getBytes(0, result)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(key: String): CompletableFuture<ByteArray?> {
|
||||
return executeWithRetry {
|
||||
sendRequest(profile.serverURI.resolve(key), HttpMethod.GET, null)
|
||||
}.thenApply {
|
||||
val status = it.status()
|
||||
if (it.status() == HttpResponseStatus.NOT_FOUND) {
|
||||
null
|
||||
} else if (it.status() != HttpResponseStatus.OK) {
|
||||
throw HttpException(status)
|
||||
} else {
|
||||
it.content()
|
||||
}
|
||||
}.thenApply { maybeByteBuf ->
|
||||
maybeByteBuf?.let {
|
||||
val result = ByteArray(it.readableBytes())
|
||||
it.getBytes(0, result)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun put(key: String, content: ByteArray): CompletableFuture<Unit> {
|
||||
return executeWithRetry {
|
||||
sendRequest(profile.serverURI.resolve(key), HttpMethod.PUT, content)
|
||||
}.thenApply {
|
||||
val status = it.status()
|
||||
if (it.status() != HttpResponseStatus.CREATED && it.status() != HttpResponseStatus.OK) {
|
||||
throw HttpException(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRequest(uri: URI, method: HttpMethod, body: ByteArray?): CompletableFuture<FullHttpResponse> {
|
||||
val responseFuture = CompletableFuture<FullHttpResponse>()
|
||||
// Custom handler for processing responses
|
||||
pool.acquire().addListener(object : GenericFutureListener<NettyFuture<Channel>> {
|
||||
override fun operationComplete(channelFuture: Future<Channel>) {
|
||||
if (channelFuture.isSuccess) {
|
||||
val channel = channelFuture.now
|
||||
val pipeline = channel.pipeline()
|
||||
channel.pipeline().addLast("handler", object : SimpleChannelInboundHandler<FullHttpResponse>() {
|
||||
override fun channelRead0(
|
||||
ctx: ChannelHandlerContext,
|
||||
response: FullHttpResponse
|
||||
) {
|
||||
pipeline.removeLast()
|
||||
pool.release(channel)
|
||||
responseFuture.complete(response)
|
||||
}
|
||||
|
||||
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
|
||||
val ex = when (cause) {
|
||||
is DecoderException -> cause.cause
|
||||
else -> cause
|
||||
}
|
||||
responseFuture.completeExceptionally(ex)
|
||||
ctx.close()
|
||||
pipeline.removeLast()
|
||||
pool.release(channel)
|
||||
}
|
||||
})
|
||||
// Prepare the HTTP request
|
||||
val request: FullHttpRequest = let {
|
||||
val content: ByteBuf? = body?.takeIf(ByteArray::isNotEmpty)?.let(Unpooled::wrappedBuffer)
|
||||
DefaultFullHttpRequest(
|
||||
HttpVersion.HTTP_1_1,
|
||||
method,
|
||||
uri.rawPath,
|
||||
content ?: Unpooled.buffer(0)
|
||||
).apply {
|
||||
headers().apply {
|
||||
if (content != null) {
|
||||
set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.APPLICATION_OCTET_STREAM)
|
||||
set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes())
|
||||
}
|
||||
set(HttpHeaderNames.HOST, profile.serverURI.host)
|
||||
set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE)
|
||||
set(
|
||||
HttpHeaderNames.ACCEPT_ENCODING,
|
||||
HttpHeaderValues.GZIP.toString() + "," + HttpHeaderValues.DEFLATE.toString()
|
||||
)
|
||||
// Add basic auth if configured
|
||||
(profile.authentication as? Configuration.Authentication.BasicAuthenticationCredentials)?.let { credentials ->
|
||||
val auth = "${credentials.username}:${credentials.password}"
|
||||
val encodedAuth = Base64.getEncoder().encodeToString(auth.toByteArray())
|
||||
set(HttpHeaderNames.AUTHORIZATION, "Basic $encodedAuth")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// Send the request
|
||||
channel.writeAndFlush(request)
|
||||
} else {
|
||||
responseFuture.completeExceptionally(channelFuture.cause())
|
||||
}
|
||||
}
|
||||
})
|
||||
return responseFuture
|
||||
}
|
||||
|
||||
fun shutDown(): NettyFuture<*> {
|
||||
return group.shutdownGracefully()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
shutDown().sync()
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
package net.woggioni.rbcs.client
|
||||
|
||||
import io.netty.handler.codec.http.HttpResponseStatus
|
||||
|
||||
class HttpException(private val status : HttpResponseStatus) : RuntimeException(status.reasonPhrase()) {
|
||||
|
||||
override val message: String
|
||||
get() = "Http status ${status.code()}: ${status.reasonPhrase()}"
|
||||
}
|
@@ -0,0 +1,108 @@
|
||||
package net.woggioni.rbcs.client.impl
|
||||
|
||||
import net.woggioni.rbcs.api.exception.ConfigurationException
|
||||
import net.woggioni.rbcs.client.RemoteBuildCacheClient
|
||||
import net.woggioni.rbcs.common.Xml.Companion.asIterable
|
||||
import net.woggioni.rbcs.common.Xml.Companion.renderAttribute
|
||||
import org.w3c.dom.Document
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.time.Duration
|
||||
|
||||
object Parser {
|
||||
|
||||
fun parse(document: Document): RemoteBuildCacheClient.Configuration {
|
||||
val root = document.documentElement
|
||||
val profiles = mutableMapOf<String, RemoteBuildCacheClient.Configuration.Profile>()
|
||||
|
||||
for (child in root.asIterable()) {
|
||||
val tagName = child.localName
|
||||
when (tagName) {
|
||||
"profile" -> {
|
||||
val name =
|
||||
child.renderAttribute("name") ?: throw ConfigurationException("name attribute is required")
|
||||
val uri = child.renderAttribute("base-url")?.let(::URI)
|
||||
?: throw ConfigurationException("base-url attribute is required")
|
||||
var authentication: RemoteBuildCacheClient.Configuration.Authentication? = null
|
||||
var retryPolicy: RemoteBuildCacheClient.Configuration.RetryPolicy? = null
|
||||
for (gchild in child.asIterable()) {
|
||||
when (gchild.localName) {
|
||||
"tls-client-auth" -> {
|
||||
val keyStoreFile = gchild.renderAttribute("key-store-file")
|
||||
val keyStorePassword =
|
||||
gchild.renderAttribute("key-store-password")
|
||||
val keyAlias = gchild.renderAttribute("key-alias")
|
||||
val keyPassword = gchild.renderAttribute("key-password")
|
||||
|
||||
val keystore = KeyStore.getInstance("PKCS12").apply {
|
||||
Files.newInputStream(Path.of(keyStoreFile)).use {
|
||||
load(it, keyStorePassword?.toCharArray())
|
||||
}
|
||||
}
|
||||
val key = keystore.getKey(keyAlias, keyPassword?.toCharArray()) as PrivateKey
|
||||
val certChain = keystore.getCertificateChain(keyAlias).asSequence()
|
||||
.map { it as X509Certificate }
|
||||
.toList()
|
||||
.toTypedArray()
|
||||
authentication =
|
||||
RemoteBuildCacheClient.Configuration.Authentication.TlsClientAuthenticationCredentials(
|
||||
key,
|
||||
certChain
|
||||
)
|
||||
}
|
||||
|
||||
"basic-auth" -> {
|
||||
val username = gchild.renderAttribute("user")
|
||||
?: throw ConfigurationException("username attribute is required")
|
||||
val password = gchild.renderAttribute("password")
|
||||
?: throw ConfigurationException("password attribute is required")
|
||||
authentication =
|
||||
RemoteBuildCacheClient.Configuration.Authentication.BasicAuthenticationCredentials(
|
||||
username,
|
||||
password
|
||||
)
|
||||
}
|
||||
|
||||
"retry-policy" -> {
|
||||
val maxAttempts =
|
||||
gchild.renderAttribute("max-attempts")
|
||||
?.let(String::toInt)
|
||||
?: throw ConfigurationException("max-attempts attribute is required")
|
||||
val initialDelay =
|
||||
gchild.renderAttribute("initial-delay")
|
||||
?.let(Duration::parse)
|
||||
?: Duration.ofSeconds(1)
|
||||
val exp =
|
||||
gchild.renderAttribute("exp")
|
||||
?.let(String::toDouble)
|
||||
?: 2.0f
|
||||
retryPolicy = RemoteBuildCacheClient.Configuration.RetryPolicy(
|
||||
maxAttempts,
|
||||
initialDelay.toMillis(),
|
||||
exp.toDouble()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val maxConnections = child.renderAttribute("max-connections")
|
||||
?.let(String::toInt)
|
||||
?: 50
|
||||
val connectionTimeout = child.renderAttribute("connection-timeout")
|
||||
?.let(Duration::parse)
|
||||
profiles[name] = RemoteBuildCacheClient.Configuration.Profile(
|
||||
uri,
|
||||
authentication,
|
||||
connectionTimeout,
|
||||
maxConnections,
|
||||
retryPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return RemoteBuildCacheClient.Configuration(profiles)
|
||||
}
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
package net.woggioni.rbcs.client
|
||||
|
||||
import io.netty.util.concurrent.EventExecutorGroup
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
sealed class OperationOutcome<T> {
|
||||
class Success<T>(val result: T) : OperationOutcome<T>()
|
||||
class Failure<T>(val ex: Throwable) : OperationOutcome<T>()
|
||||
}
|
||||
|
||||
sealed class OutcomeHandlerResult {
|
||||
class Retry(val suggestedDelayMillis: Long? = null) : OutcomeHandlerResult()
|
||||
class DoNotRetry : OutcomeHandlerResult()
|
||||
}
|
||||
|
||||
fun interface OutcomeHandler<T> {
|
||||
fun shouldRetry(result: OperationOutcome<T>): OutcomeHandlerResult
|
||||
}
|
||||
|
||||
fun <T> executeWithRetry(
|
||||
eventExecutorGroup: EventExecutorGroup,
|
||||
maxAttempts: Int,
|
||||
initialDelay: Double,
|
||||
exp: Double,
|
||||
outcomeHandler: OutcomeHandler<T>,
|
||||
cb: () -> CompletableFuture<T>
|
||||
): CompletableFuture<T> {
|
||||
val finalResult = cb()
|
||||
var future = finalResult
|
||||
var shortCircuit = false
|
||||
for (i in 1 until maxAttempts) {
|
||||
future = future.handle { result, ex ->
|
||||
val operationOutcome = if (ex == null) {
|
||||
OperationOutcome.Success(result)
|
||||
} else {
|
||||
OperationOutcome.Failure(ex.cause ?: ex)
|
||||
}
|
||||
if (shortCircuit) {
|
||||
when(operationOutcome) {
|
||||
is OperationOutcome.Failure -> throw operationOutcome.ex
|
||||
is OperationOutcome.Success -> CompletableFuture.completedFuture(operationOutcome.result)
|
||||
}
|
||||
} else {
|
||||
when(val outcomeHandlerResult = outcomeHandler.shouldRetry(operationOutcome)) {
|
||||
is OutcomeHandlerResult.Retry -> {
|
||||
val res = CompletableFuture<T>()
|
||||
val delay = run {
|
||||
val scheduledDelay = (initialDelay * Math.pow(exp, i.toDouble())).toLong()
|
||||
outcomeHandlerResult.suggestedDelayMillis?.coerceAtMost(scheduledDelay) ?: scheduledDelay
|
||||
}
|
||||
eventExecutorGroup.schedule({
|
||||
cb().handle { result, ex ->
|
||||
if (ex == null) {
|
||||
res.complete(result)
|
||||
} else {
|
||||
res.completeExceptionally(ex)
|
||||
}
|
||||
}
|
||||
}, delay, TimeUnit.MILLISECONDS)
|
||||
res
|
||||
}
|
||||
is OutcomeHandlerResult.DoNotRetry -> {
|
||||
shortCircuit = true
|
||||
when(operationOutcome) {
|
||||
is OperationOutcome.Failure -> throw operationOutcome.ex
|
||||
is OperationOutcome.Success -> CompletableFuture.completedFuture(operationOutcome.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.thenCompose { it }
|
||||
}
|
||||
return future
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xs:schema targetNamespace="urn:net.woggioni.rbcs.client"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:rbcs-client="urn:net.woggioni.rbcs.client"
|
||||
elementFormDefault="unqualified"
|
||||
>
|
||||
<xs:element name="profiles" type="rbcs-client:profilesType"/>
|
||||
|
||||
<xs:complexType name="profilesType">
|
||||
<xs:sequence minOccurs="0">
|
||||
<xs:element name="profile" type="rbcs-client:profileType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="profileType">
|
||||
<xs:sequence>
|
||||
<xs:choice>
|
||||
<xs:element name="no-auth" type="rbcs-client:noAuthType"/>
|
||||
<xs:element name="basic-auth" type="rbcs-client:basicAuthType"/>
|
||||
<xs:element name="tls-client-auth" type="rbcs-client:tlsClientAuthType"/>
|
||||
</xs:choice>
|
||||
<xs:element name="retry-policy" type="rbcs-client:retryType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="name" type="xs:token" use="required"/>
|
||||
<xs:attribute name="base-url" type="xs:anyURI" use="required"/>
|
||||
<xs:attribute name="max-connections" type="xs:positiveInteger" default="50"/>
|
||||
<xs:attribute name="connection-timeout" type="xs:duration"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="noAuthType"/>
|
||||
|
||||
<xs:complexType name="basicAuthType">
|
||||
<xs:attribute name="user" type="xs:token" use="required"/>
|
||||
<xs:attribute name="password" type="xs:string" use="required"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="tlsClientAuthType">
|
||||
<xs:attribute name="key-store-file" type="xs:anyURI" use="required"/>
|
||||
<xs:attribute name="key-store-password" type="xs:string" use="required"/>
|
||||
<xs:attribute name="key-alias" type="xs:token" use="required"/>
|
||||
<xs:attribute name="key-password" type="xs:string" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="retryType">
|
||||
<xs:attribute name="max-attempts" type="xs:positiveInteger" use="required"/>
|
||||
<xs:attribute name="initial-delay" type="xs:duration" default="PT1S"/>
|
||||
<xs:attribute name="exp" type="xs:double" default="2.0"/>
|
||||
</xs:complexType>
|
||||
|
||||
</xs:schema>
|
@@ -0,0 +1,148 @@
|
||||
package net.woggioni.rbcs.client
|
||||
|
||||
import io.netty.util.concurrent.DefaultEventExecutorGroup
|
||||
import io.netty.util.concurrent.EventExecutorGroup
|
||||
import net.woggioni.rbcs.common.contextLogger
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.extension.ExtensionContext
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.ArgumentsProvider
|
||||
import org.junit.jupiter.params.provider.ArgumentsSource
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.stream.Stream
|
||||
import kotlin.random.Random
|
||||
|
||||
class RetryTest {
|
||||
|
||||
data class TestArgs(
|
||||
val seed: Int,
|
||||
val maxAttempt: Int,
|
||||
val initialDelay: Double,
|
||||
val exp: Double,
|
||||
)
|
||||
|
||||
class TestArguments : ArgumentsProvider {
|
||||
override fun provideArguments(context: ExtensionContext): Stream<out Arguments> {
|
||||
return Stream.of(
|
||||
TestArgs(
|
||||
seed = 101325,
|
||||
maxAttempt = 5,
|
||||
initialDelay = 50.0,
|
||||
exp = 2.0,
|
||||
),
|
||||
TestArgs(
|
||||
seed = 101325,
|
||||
maxAttempt = 20,
|
||||
initialDelay = 100.0,
|
||||
exp = 1.1,
|
||||
),
|
||||
TestArgs(
|
||||
seed = 123487,
|
||||
maxAttempt = 20,
|
||||
initialDelay = 100.0,
|
||||
exp = 2.0,
|
||||
),
|
||||
TestArgs(
|
||||
seed = 20082024,
|
||||
maxAttempt = 10,
|
||||
initialDelay = 100.0,
|
||||
exp = 2.0,
|
||||
)
|
||||
).map {
|
||||
object: Arguments {
|
||||
override fun get() = arrayOf(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ArgumentsSource(TestArguments::class)
|
||||
@ParameterizedTest
|
||||
fun test(testArgs: TestArgs) {
|
||||
val log = contextLogger()
|
||||
log.debug("Start")
|
||||
val executor: EventExecutorGroup = DefaultEventExecutorGroup(1)
|
||||
val attempts = mutableListOf<Pair<Long, OperationOutcome<Int>>>()
|
||||
val outcomeHandler = OutcomeHandler<Int> { outcome ->
|
||||
when(outcome) {
|
||||
is OperationOutcome.Success -> {
|
||||
if(outcome.result % 10 == 0) {
|
||||
OutcomeHandlerResult.DoNotRetry()
|
||||
} else {
|
||||
OutcomeHandlerResult.Retry(null)
|
||||
}
|
||||
}
|
||||
is OperationOutcome.Failure -> {
|
||||
when(outcome.ex) {
|
||||
is IllegalStateException -> {
|
||||
log.debug(outcome.ex.message, outcome.ex)
|
||||
OutcomeHandlerResult.Retry(null)
|
||||
}
|
||||
else -> {
|
||||
OutcomeHandlerResult.DoNotRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val random = Random(testArgs.seed)
|
||||
|
||||
val future =
|
||||
executeWithRetry(executor, testArgs.maxAttempt, testArgs.initialDelay, testArgs.exp, outcomeHandler) {
|
||||
val now = System.nanoTime()
|
||||
val result = CompletableFuture<Int>()
|
||||
executor.submit {
|
||||
val n = random.nextInt(0, Integer.MAX_VALUE)
|
||||
log.debug("Got new number: {}", n)
|
||||
if(n % 3 == 0) {
|
||||
val ex = IllegalStateException("Value $n can be divided by 3")
|
||||
result.completeExceptionally(ex)
|
||||
attempts += now to OperationOutcome.Failure(ex)
|
||||
} else if(n % 7 == 0) {
|
||||
val ex = RuntimeException("Value $n can be divided by 7")
|
||||
result.completeExceptionally(ex)
|
||||
attempts += now to OperationOutcome.Failure(ex)
|
||||
} else {
|
||||
result.complete(n)
|
||||
attempts += now to OperationOutcome.Success(n)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
Assertions.assertTrue(attempts.size <= testArgs.maxAttempt)
|
||||
val result = future.handle { res, ex ->
|
||||
if(ex != null) {
|
||||
val err = ex.cause ?: ex
|
||||
log.debug(err.message, err)
|
||||
OperationOutcome.Failure(err)
|
||||
} else {
|
||||
OperationOutcome.Success(res)
|
||||
}
|
||||
}.get()
|
||||
for ((index, attempt) in attempts.withIndex()) {
|
||||
val (timestamp, value) = attempt
|
||||
if (index > 0) {
|
||||
/* Check the delay for subsequent attempts is correct */
|
||||
val previousAttempt = attempts[index - 1]
|
||||
val expectedTimestamp =
|
||||
previousAttempt.first + testArgs.initialDelay * Math.pow(testArgs.exp, index.toDouble()) * 1e6
|
||||
val actualTimestamp = timestamp
|
||||
val err = Math.abs(expectedTimestamp - actualTimestamp) / expectedTimestamp
|
||||
Assertions.assertTrue(err < 1e-3)
|
||||
}
|
||||
if (index == attempts.size - 1 && index < testArgs.maxAttempt - 1) {
|
||||
/*
|
||||
* If the last attempt index is lower than the maximum number of attempts, then
|
||||
* check the outcome handler returns DoNotRetry
|
||||
*/
|
||||
Assertions.assertTrue(outcomeHandler.shouldRetry(value) is OutcomeHandlerResult.DoNotRetry)
|
||||
} else if (index < attempts.size - 1) {
|
||||
/*
|
||||
* If the attempt is not the last attempt check the outcome handler returns Retry
|
||||
*/
|
||||
Assertions.assertTrue(outcomeHandler.shouldRetry(value) is OutcomeHandlerResult.Retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
rbcs-client/src/test/resources/logback.xml
Normal file
21
rbcs-client/src/test/resources/logback.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE configuration>
|
||||
|
||||
<configuration>
|
||||
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/>
|
||||
<import class="ch.qos.logback.core.ConsoleAppender"/>
|
||||
|
||||
<appender name="console" class="ConsoleAppender">
|
||||
<target>System.err</target>
|
||||
<encoder class="PatternLayoutEncoder">
|
||||
<pattern>%d [%highlight(%-5level)] \(%thread\) %logger{36} -%kvp- %msg %n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="console"/>
|
||||
</root>
|
||||
<logger name="io.netty" level="info"/>
|
||||
<logger name="com.google.code.yanf4j" level="warn"/>
|
||||
<logger name="net.rubyeye.xmemcached" level="warn"/>
|
||||
</configuration>
|
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<rbcs-client:profiles xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:rbcs-client="urn:net.woggioni.rbcs.client"
|
||||
xs:schemaLocation="urn:net.woggioni.rbcs.client jms://net.woggioni.rbcs.client/net/woggioni/rbcs/client/schema/rbcs-client.xsd"
|
||||
>
|
||||
<profile name="profile1" base-url="https://rbcs1.example.com/">
|
||||
<tls-client-auth
|
||||
key-store-file="keystore.pfx"
|
||||
key-store-password="password"
|
||||
key-alias="woggioni@c962475fa38"
|
||||
key-password="key-password"/>
|
||||
</profile>
|
||||
<profile name="profile2" base-url="https://rbcs2.example.com/">
|
||||
<basic-auth user="user" password="password"/>
|
||||
</profile>
|
||||
</rbcs-client:profiles>
|
Reference in New Issue
Block a user