feat(redis): migrate from Redisson to Quarkus Redis client and update configuration

This commit is contained in:
2026-04-28 22:44:10 +02:00
parent 0652dd2d2f
commit 5d97c3c8b5
58 changed files with 209 additions and 504 deletions
+2 -3
View File
@@ -47,7 +47,7 @@ val coverageExclusions = listOf(
"**/account/src/main/scala/de/nowchess/account/resource/**", "**/account/src/main/scala/de/nowchess/account/resource/**",
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic // JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
"**/account/src/main/scala/de/nowchess/account/config/**", "**/account/src/main/scala/de/nowchess/account/config/**",
// WebSocket service — infrastructure CDI beans (RedisConfig, RedissonProducer) // WebSocket service — infrastructure CDI beans (RedisConfig)
"**/ws/src/main/scala/de/nowchess/ws/config/**", "**/ws/src/main/scala/de/nowchess/ws/config/**",
// GameWebSocketResource in core — replaced by ws module // GameWebSocketResource in core — replaced by ws module
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala", "**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
@@ -99,8 +99,7 @@ val versions = mapOf(
"SCALA_PARSER_COMBINATORS" to "2.4.0", "SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2", "FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2", "JACKSON" to "2.17.2",
"JACKSON_SCALA" to "2.17.2", "JACKSON_SCALA" to "2.17.2"
"REDISSON" to "3.32.0"
) )
extra["VERSIONS"] = versions extra["VERSIONS"] = versions
+1 -1
View File
@@ -62,7 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer") implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi") implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -1,5 +0,0 @@
quarkus.native.additional-build-args=\
--initialize-at-run-time=org.redisson,\
--initialize-at-run-time=io.netty
%native.quarkus.arc.exclude-types=org.redisson.*
@@ -3,6 +3,8 @@ quarkus:
port: 8083 port: 8083
application: application:
name: nowchess-account name: nowchess-account
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client: rest-client:
core-service: core-service:
url: http://localhost:8080 url: http://localhost:8080
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -1,34 +0,0 @@
package de.nowchess.account.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.{Config, TransportMode}
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -1,30 +1,30 @@
package de.nowchess.account.service package de.nowchess.account.service
import de.nowchess.account.config.RedisConfig import de.nowchess.account.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class EventPublisher: class EventPublisher:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit = def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}""" val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event) redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
() ()
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit = def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}""" val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$destUserId:events").publish(event) redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$destUserId:events", event)
() ()
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit = def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}""" val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$challengerId:events").publish(event) redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$challengerId:events", event)
() ()
@@ -29,3 +29,8 @@ smallrye:
sign: sign:
key: key:
location: keys/test-private.pem location: keys/test-private.pem
nowchess:
internal:
secret: test-secret
auth:
enabled: false
+1 -1
View File
@@ -62,7 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-smallrye-health") implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-smallrye-openapi") implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
implementation(project(":modules:api")) implementation(project(":modules:api"))
@@ -3,6 +3,8 @@ quarkus:
port: 8087 port: 8087
application: application:
name: nowchess-bot-platform name: nowchess-bot-platform
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
smallrye-jwt: smallrye-jwt:
enabled: true enabled: true
log: log:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -1,35 +0,0 @@
package de.nowchess.botplatform.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -1,43 +1,39 @@
package de.nowchess.botplatform.registry package de.nowchess.botplatform.registry
import de.nowchess.botplatform.config.RedisConfig import de.nowchess.botplatform.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import io.smallrye.mutiny.subscription.MultiEmitter import io.smallrye.mutiny.subscription.MultiEmitter
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@ApplicationScoped @ApplicationScoped
class BotRegistry: class BotRegistry:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val connections = ConcurrentHashMap[String, (MultiEmitter[?], Int)]() private val connections = ConcurrentHashMap[String, (MultiEmitter[?], PubSubCommands.RedisSubscriber)]()
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit = def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events") val channel = s"${redisConfig.prefix}:bot:$botId:events"
val listenerId = topic.addListener( val handler: Consumer[String] = msg => emitter.asInstanceOf[MultiEmitter[String]].emit(msg)
classOf[String], val subscriber = redis.pubsub(classOf[String]).subscribe(channel, handler)
new MessageListener[String]: connections.put(botId, (emitter, subscriber))
def onMessage(channel: CharSequence, msg: String): Unit =
emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
)
connections.put(botId, (emitter, listenerId))
() ()
def unregister(botId: String): Unit = def unregister(botId: String): Unit =
Option(connections.remove(botId)).foreach { (_, listenerId) => Option(connections.remove(botId)).foreach { (_, subscriber) =>
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId) subscriber.unsubscribe(s"${redisConfig.prefix}:bot:$botId:events")
} }
def dispatch(botId: String, event: String): Unit = def dispatch(botId: String, event: String): Unit =
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event) redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
() ()
def registeredBots: List[String] = def registeredBots: List[String] =
@@ -2,6 +2,7 @@ package de.nowchess.botplatform.resource
import de.nowchess.botplatform.config.RedisConfig import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotRegistry import de.nowchess.botplatform.registry.BotRegistry
import io.quarkus.redis.datasource.RedisDataSource
import io.smallrye.mutiny.Multi import io.smallrye.mutiny.Multi
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
@@ -9,9 +10,8 @@ import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response} import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken import org.eclipse.microprofile.jwt.JsonWebToken
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.util.function.Consumer
@Path("/api/bot") @Path("/api/bot")
@ApplicationScoped @ApplicationScoped
@@ -21,7 +21,7 @@ class BotEventResource:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var registry: BotRegistry = uninitialized @Inject var registry: BotRegistry = uninitialized
@Inject var jwt: JsonWebToken = uninitialized @Inject var jwt: JsonWebToken = uninitialized
@Inject var redisson: RedissonClient = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -44,14 +44,10 @@ class BotEventResource:
@Produces(Array(MediaType.SERVER_SENT_EVENTS)) @Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] = def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter => Multi.createFrom().emitter[String] { emitter =>
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c" val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName) val handler: Consumer[String] = msg => emitter.emit(msg)
val listenerId = topic.addListener( val subscriber = redis.pubsub(classOf[String]).subscribe(topicName, handler)
classOf[String], emitter.onTermination(() => subscriber.unsubscribe(topicName))
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
)
emitter.onTermination(() => topic.removeListener(listenerId))
} }
@POST @POST
@@ -63,5 +59,5 @@ class BotEventResource:
): Response = ): Response =
val playerId = Option(jwt.getSubject).getOrElse("") val playerId = Option(jwt.getSubject).getOrElse("")
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}""" val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg) redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
Response.ok().build() Response.ok().build()
+1 -1
View File
@@ -72,7 +72,7 @@ dependencies {
implementation("io.quarkus:quarkus-rest-client") implementation("io.quarkus:quarkus-rest-client")
implementation("io.quarkus:quarkus-rest-client-jackson") implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
implementation("io.fabric8:kubernetes-client:6.13.0") implementation("io.fabric8:kubernetes-client:6.13.0")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}")) testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
@@ -3,6 +3,8 @@ quarkus:
name: nowchess-coordinator name: nowchess-coordinator
http: http:
port: 8086 port: 8086
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
grpc: grpc:
server: server:
port: 9086 port: 9086
@@ -4,29 +4,9 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces import jakarta.enterprise.inject.Produces
import io.fabric8.kubernetes.client.KubernetesClientBuilder import io.fabric8.kubernetes.client.KubernetesClientBuilder
import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClient
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import org.eclipse.microprofile.config.inject.ConfigProperty
import jakarta.inject.Inject
import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class BeansProducer: class BeansProducer:
@Inject
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
private var redisHost: String = uninitialized
@Inject
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
private var redisPort: Int = uninitialized
@Produces
@ApplicationScoped
def redissonClient: RedissonClient =
val config = Config()
config.useSingleServer().setAddress(s"redis://$redisHost:$redisPort")
Redisson.create(config)
@Produces @Produces
@ApplicationScoped @ApplicationScoped
@@ -2,7 +2,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import de.nowchess.coordinator.config.CoordinatorConfig import de.nowchess.coordinator.config.CoordinatorConfig
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
@@ -15,7 +15,7 @@ import de.nowchess.coordinator.grpc.CoreGrpcClient
@ApplicationScoped @ApplicationScoped
class CacheEvictionManager: class CacheEvictionManager:
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
@Inject @Inject
private var config: CoordinatorConfig = uninitialized private var config: CoordinatorConfig = uninitialized
@@ -39,7 +39,7 @@ class CacheEvictionManager:
log.info("Starting cache eviction scan") log.info("Starting cache eviction scan")
val pattern = s"$redisPrefix:game:entry:*" val pattern = s"$redisPrefix:game:entry:*"
val keys = redissonClient.getKeys.getKeysByPattern(pattern, 100) val keys = redis.key(classOf[String]).keys(pattern)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val idleThresholdMs = config.gameIdleThreshold.toMillis val idleThresholdMs = config.gameIdleThreshold.toMillis
@@ -47,8 +47,7 @@ class CacheEvictionManager:
var evictedCount = 0 var evictedCount = 0
keys.asScala.foreach { key => keys.asScala.foreach { key =>
try try
val bucket = redissonClient.getBucket[String](key) val value = redis.value(classOf[String]).get(key)
val value = bucket.get()
if value != null then if value != null then
val gameId = key.stripPrefix(s"$redisPrefix:game:entry:") val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
val lastUpdated = extractLastUpdatedTimestamp(value) val lastUpdated = extractLastUpdatedTimestamp(value)
@@ -57,7 +56,7 @@ class CacheEvictionManager:
findInstanceWithGame(gameId).foreach { instance => findInstanceWithGame(gameId).foreach { instance =>
try try
coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId)) coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId))
bucket.delete() redis.key(classOf[String]).del(key)
evictedCount += 1 evictedCount += 1
log.infof("Evicted idle game %s from %s", gameId, instance.instanceId) log.infof("Evicted idle game %s from %s", gameId, instance.instanceId)
catch catch
@@ -82,9 +81,8 @@ class CacheEvictionManager:
private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] = private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] =
try try
instanceRegistry.getAllInstances.find { instance => instanceRegistry.getAllInstances.find { instance =>
val setKey = s"$redisPrefix:instance:${instance.instanceId}:games" val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
val gameSet = redissonClient.getSet[String](setKey) redis.set(classOf[String]).sismember(setKey, gameId)
gameSet.contains(gameId)
} }
catch catch
case ex: Exception => case ex: Exception =>
@@ -2,7 +2,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import org.jboss.logging.Logger import org.jboss.logging.Logger
@@ -12,7 +12,7 @@ import de.nowchess.coordinator.grpc.CoreGrpcClient
@ApplicationScoped @ApplicationScoped
class FailoverService: class FailoverService:
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
@Inject @Inject
private var instanceRegistry: InstanceRegistry = uninitialized private var instanceRegistry: InstanceRegistry = uninitialized
@@ -50,9 +50,8 @@ class FailoverService:
cleanupDeadInstance(instanceId) cleanupDeadInstance(instanceId)
private def getOrphanedGames(instanceId: String): List[String] = private def getOrphanedGames(instanceId: String): List[String] =
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey) redis.set(classOf[String]).smembers(setKey).asScala.toList
gameSet.readAll.asScala.toList
private def distributeGames( private def distributeGames(
gameIds: List[String], gameIds: List[String],
@@ -87,7 +86,6 @@ class FailoverService:
} }
private def cleanupDeadInstance(instanceId: String): Unit = private def cleanupDeadInstance(instanceId: String): Unit =
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey) redis.key(classOf[String]).del(setKey)
gameSet.delete()
log.infof("Cleaned up games set for instance %s", instanceId) log.infof("Cleaned up games set for instance %s", instanceId)
@@ -5,7 +5,7 @@ import jakarta.inject.Inject
import de.nowchess.coordinator.config.CoordinatorConfig import de.nowchess.coordinator.config.CoordinatorConfig
import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClient
import io.fabric8.kubernetes.api.model.Pod import io.fabric8.kubernetes.api.model.Pod
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
import org.jboss.logging.Logger import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@@ -23,7 +23,7 @@ class HealthMonitor:
private var instanceRegistry: InstanceRegistry = uninitialized private var instanceRegistry: InstanceRegistry = uninitialized
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
private val log = Logger.getLogger(classOf[HealthMonitor]) private val log = Logger.getLogger(classOf[HealthMonitor])
private var redisPrefix = "nowchess" private var redisPrefix = "nowchess"
@@ -47,10 +47,8 @@ class HealthMonitor:
private def checkRedisHeartbeat(instanceId: String): Boolean = private def checkRedisHeartbeat(instanceId: String): Boolean =
try try
val key = s"$redisPrefix:instances:$instanceId" val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key) redis.key(classOf[String]).pttl(key) > 0
val ttl = bucket.remainTimeToLive()
ttl > 0
catch catch
case ex: Exception => case ex: Exception =>
log.debugf(ex, "Redis heartbeat check failed for %s", instanceId) log.debugf(ex, "Redis heartbeat check failed for %s", instanceId)
@@ -2,7 +2,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
@@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped @ApplicationScoped
class InstanceRegistry: class InstanceRegistry:
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
private val mapper = ObjectMapper() private val mapper = ObjectMapper()
private val instances = ConcurrentHashMap[String, InstanceMetadata]() private val instances = ConcurrentHashMap[String, InstanceMetadata]()
@@ -28,9 +28,8 @@ class InstanceRegistry:
instances.values.asScala.toList instances.values.asScala.toList
def updateInstanceFromRedis(instanceId: String): Unit = def updateInstanceFromRedis(instanceId: String): Unit =
val key = s"$redisPrefix:instances:$instanceId" val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key) val value = redis.value(classOf[String]).get(key)
val value = bucket.get()
if value != null then if value != null then
try try
val metadata = mapper.readValue(value, classOf[InstanceMetadata]) val metadata = mapper.readValue(value, classOf[InstanceMetadata])
@@ -3,7 +3,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import de.nowchess.coordinator.config.CoordinatorConfig import de.nowchess.coordinator.config.CoordinatorConfig
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import org.jboss.logging.Logger import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.concurrent.duration.* import scala.concurrent.duration.*
@@ -19,7 +19,7 @@ class LoadBalancer:
private var instanceRegistry: InstanceRegistry = uninitialized private var instanceRegistry: InstanceRegistry = uninitialized
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
@Inject @Inject
private var coreGrpcClient: CoreGrpcClient = uninitialized private var coreGrpcClient: CoreGrpcClient = uninitialized
@@ -107,9 +107,8 @@ class LoadBalancer:
private def getGamesToMove(instanceId: String, count: Int): List[String] = private def getGamesToMove(instanceId: String, count: Int): List[String] =
try try
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey) redis.set(classOf[String]).smembers(setKey).asScala.toList.take(count)
gameSet.readAll.asScala.toList.take(count)
catch catch
case ex: Exception => case ex: Exception =>
log.debugf(ex, "Failed to get games for %s", instanceId) log.debugf(ex, "Failed to get games for %s", instanceId)
@@ -120,12 +119,9 @@ class LoadBalancer:
val fromKey = s"$redisPrefix:instance:$fromInstanceId:games" val fromKey = s"$redisPrefix:instance:$fromInstanceId:games"
val toKey = s"$redisPrefix:instance:$toInstanceId:games" val toKey = s"$redisPrefix:instance:$toInstanceId:games"
val fromSet = redissonClient.getSet[String](fromKey)
val toSet = redissonClient.getSet[String](toKey)
gameIds.foreach { gameId => gameIds.foreach { gameId =>
fromSet.remove(gameId) redis.set(classOf[String]).srem(fromKey, gameId)
toSet.add(gameId) redis.set(classOf[String]).sadd(toKey, gameId)
} }
catch catch
case ex: Exception => case ex: Exception =>
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies {
implementation("io.quarkus:quarkus-websockets-next") implementation("io.quarkus:quarkus-websockets-next")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
testImplementation(project(":modules:io")) testImplementation(project(":modules:io"))
testImplementation(project(":modules:rule")) testImplementation(project(":modules:rule"))
@@ -3,6 +3,8 @@ quarkus:
port: 8080 port: 8080
application: application:
name: nowchess-core name: nowchess-core
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
grpc: grpc:
clients: clients:
rule-grpc: rule-grpc:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -1,35 +0,0 @@
package de.nowchess.chess.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -9,12 +9,12 @@ import de.nowchess.api.board.Color
import de.nowchess.chess.observer.{GameEvent, Observer} import de.nowchess.chess.observer.{GameEvent, Observer}
import de.nowchess.chess.registry.GameRegistry import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper import de.nowchess.chess.resource.GameDtoMapper
import org.redisson.api.RTopic import io.quarkus.redis.datasource.RedisDataSource
class GameRedisPublisher( class GameRedisPublisher(
gameId: String, gameId: String,
registry: GameRegistry, registry: GameRegistry,
redisson: org.redisson.api.RedissonClient, redis: RedisDataSource,
objectMapper: ObjectMapper, objectMapper: ObjectMapper,
s2cTopicName: String, s2cTopicName: String,
writebackEmit: String => Unit, writebackEmit: String => Unit,
@@ -26,7 +26,7 @@ class GameRedisPublisher(
registry.get(gameId).foreach { entry => registry.get(gameId).foreach { entry =>
val dto = GameDtoMapper.toGameStateDto(entry, ioClient) val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
val json = objectMapper.writeValueAsString(GameStateEventDto(dto)) val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
redisson.getTopic(s2cTopicName).publish(json) redis.pubsub(classOf[String]).publish(s2cTopicName, json)
val clock = entry.engine.currentClockState val clock = entry.engine.currentClockState
val wb = GameWritebackEventDto( val wb = GameWritebackEventDto(
@@ -10,20 +10,21 @@ import de.nowchess.chess.observer.Observer
import de.nowchess.chess.registry.GameRegistry import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper import de.nowchess.chess.resource.GameDtoMapper
import de.nowchess.chess.service.InstanceHeartbeatService import de.nowchess.chess.service.InstanceHeartbeatService
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.util.Try import scala.util.Try
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@ApplicationScoped @ApplicationScoped
class GameRedisSubscriberManager: class GameRedisSubscriberManager:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var registry: GameRegistry = uninitialized @Inject var registry: GameRegistry = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@@ -31,7 +32,7 @@ class GameRedisSubscriberManager:
@Inject var heartbeatService: InstanceHeartbeatService = null @Inject var heartbeatService: InstanceHeartbeatService = null
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val c2sListeners = new ConcurrentHashMap[String, Int]() private val c2sListeners = new ConcurrentHashMap[String, PubSubCommands.RedisSubscriber]()
private val s2cObservers = new ConcurrentHashMap[String, Observer]() private val s2cObservers = new ConcurrentHashMap[String, Observer]()
private def c2sTopic(gameId: String): String = private def c2sTopic(gameId: String): String =
@@ -42,21 +43,15 @@ class GameRedisSubscriberManager:
def subscribeGame(gameId: String): Unit = def subscribeGame(gameId: String): Unit =
try try
val topic = redisson.getTopic(c2sTopic(gameId)) val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
val listenerId = topic.addListener( val subscriber = redis.pubsub(classOf[String]).subscribe(c2sTopic(gameId), handler)
classOf[String], c2sListeners.put(gameId, subscriber)
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleC2sMessage(gameId, msg),
)
c2sListeners.put(gameId, listenerId)
val writebackTopic = redisson.getTopic("game-writeback") val writebackFn: String => Unit = json => redis.pubsub(classOf[String]).publish("game-writeback", json)
val writebackFn: String => Unit = json => writebackTopic.publish(json)
val obs = new GameRedisPublisher( val obs = new GameRedisPublisher(
gameId, gameId,
registry, registry,
redisson, redis,
objectMapper, objectMapper,
s2cTopicName(gameId), s2cTopicName(gameId),
writebackFn, writebackFn,
@@ -73,8 +68,8 @@ class GameRedisSubscriberManager:
() ()
def unsubscribeGame(gameId: String): Unit = def unsubscribeGame(gameId: String): Unit =
Option(c2sListeners.remove(gameId)).foreach { listenerId => Option(c2sListeners.remove(gameId)).foreach { subscriber =>
redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId) subscriber.unsubscribe(c2sTopic(gameId))
} }
Option(s2cObservers.remove(gameId)).foreach { obs => Option(s2cObservers.remove(gameId)).foreach { obs =>
registry.get(gameId).foreach(_.engine.unsubscribe(obs)) registry.get(gameId).foreach(_.engine.unsubscribe(obs))
@@ -84,16 +79,16 @@ class GameRedisSubscriberManager:
private def handleC2sMessage(gameId: String, msg: String): Unit = private def handleC2sMessage(gameId: String, msg: String): Unit =
parseC2sMessage(msg) match parseC2sMessage(msg) match
case Some(C2sMessage.Connected) => handleConnected(gameId) case Some(C2sMessage.Connected) => handleConnected(gameId)
case Some(C2sMessage.Move(uci, playerId)) => handleMove(gameId, uci, playerId) case Some(C2sMessage.Move(uci, playerId)) => handleMove(gameId, uci, playerId)
case Some(C2sMessage.Ping) => () case Some(C2sMessage.Ping) => ()
case None => () case None => ()
private def handleConnected(gameId: String): Unit = private def handleConnected(gameId: String): Unit =
registry.get(gameId).foreach { entry => registry.get(gameId).foreach { entry =>
val dto = GameDtoMapper.toGameFullDto(entry, ioClient) val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
val json = objectMapper.writeValueAsString(GameFullEventDto(dto)) val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
redisson.getTopic(s2cTopicName(gameId)).publish(json) redis.pubsub(classOf[String]).publish(s2cTopicName(gameId), json)
} }
private def handleMove(gameId: String, uci: String, playerId: Option[String]): Unit = private def handleMove(gameId: String, uci: String, playerId: Option[String]): Unit =
@@ -120,8 +115,8 @@ class GameRedisSubscriberManager:
val pid = Option(node.get("playerId")).map(_.asText()).filter(_.nonEmpty) val pid = Option(node.get("playerId")).map(_.asText()).filter(_.nonEmpty)
C2sMessage.Move(u.asText(), pid) C2sMessage.Move(u.asText(), pid)
} }
case "PING" => Some(C2sMessage.Ping) case "PING" => Some(C2sMessage.Ping)
case _ => None case _ => None
} }
} }
@@ -157,5 +152,5 @@ class GameRedisSubscriberManager:
@PreDestroy @PreDestroy
def cleanup(): Unit = def cleanup(): Unit =
c2sListeners.forEach((gameId, listenerId) => redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)) c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)))
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs))) s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
@@ -12,23 +12,22 @@ import de.nowchess.chess.grpc.RuleSetGrpcAdapter
import de.nowchess.chess.config.RedisConfig import de.nowchess.chess.config.RedisConfig
import de.nowchess.chess.grpc.IoGrpcClientWrapper import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.chess.resource.GameDtoMapper import de.nowchess.chess.resource.GameDtoMapper
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient import org.eclipse.microprofile.rest.client.inject.RestClient
import org.redisson.api.RedissonClient
import scala.annotation.nowarn
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.util.Try import scala.util.Try
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.{MessageDigest, SecureRandom} import java.security.{MessageDigest, SecureRandom}
import java.time.Instant import java.time.Instant
import java.util.concurrent.{ConcurrentHashMap, TimeUnit} import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped @ApplicationScoped
class RedisGameRegistry extends GameRegistry: class RedisGameRegistry extends GameRegistry:
@Inject @Inject
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
var redisson: RedissonClient = uninitialized var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var ioClient: IoGrpcClientWrapper = uninitialized @Inject var ioClient: IoGrpcClientWrapper = uninitialized
@@ -40,7 +39,6 @@ class RedisGameRegistry extends GameRegistry:
private val rng = new SecureRandom() private val rng = new SecureRandom()
private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId" private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
def generateId(): String = def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@@ -49,9 +47,7 @@ class RedisGameRegistry extends GameRegistry:
def store(entry: GameEntry): Unit = def store(entry: GameEntry): Unit =
localEngines.put(entry.gameId, entry) localEngines.put(entry.gameId, entry)
val combined = ioClient.exportCombined(entry.engine.context) val combined = ioClient.exportCombined(entry.engine.context)
val b = bucket(entry.gameId) redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
def get(gameId: String): Option[GameEntry] = def get(gameId: String): Option[GameEntry] =
Option(localEngines.get(gameId)) match Option(localEngines.get(gameId)) match
@@ -66,12 +62,10 @@ class RedisGameRegistry extends GameRegistry:
def update(entry: GameEntry): Unit = def update(entry: GameEntry): Unit =
localEngines.put(entry.gameId, entry) localEngines.put(entry.gameId, entry)
val combined = ioClient.exportCombined(entry.engine.context) val combined = ioClient.exportCombined(entry.engine.context)
val b = bucket(entry.gameId) redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
private def readRedisDto(gameId: String): Option[GameCacheDto] = private def readRedisDto(gameId: String): Option[GameCacheDto] =
Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json => Try(Option(redis.value(classOf[String]).get(cacheKey(gameId)))).toOption.flatten.flatMap { json =>
Try(objectMapper.readValue(json, classOf[GameCacheDto])).toOption Try(objectMapper.readValue(json, classOf[GameCacheDto])).toOption
} }
@@ -111,9 +105,7 @@ class RedisGameRegistry extends GameRegistry:
}.toOption }.toOption
.map { case (dto, entry) => .map { case (dto, entry) =>
localEngines.put(gameId, entry) localEngines.put(gameId, entry)
val b = bucket(gameId) redis.value(classOf[String]).setex(cacheKey(gameId), 1800L, objectMapper.writeValueAsString(dto))
b.set(objectMapper.writeValueAsString(dto))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
entry entry
} }
@@ -7,8 +7,7 @@ import io.quarkus.runtime.StartupEvent
import io.quarkus.runtime.ShutdownEvent import io.quarkus.runtime.ShutdownEvent
import io.quarkus.grpc.GrpcClient import io.quarkus.grpc.GrpcClient
import org.eclipse.microprofile.config.inject.ConfigProperty import org.eclipse.microprofile.config.inject.ConfigProperty
import org.redisson.api.RedissonClient import io.quarkus.redis.datasource.RedisDataSource
import scala.annotation.nowarn
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.util.concurrent.{Executors, TimeUnit} import java.util.concurrent.{Executors, TimeUnit}
import java.net.InetAddress import java.net.InetAddress
@@ -22,7 +21,7 @@ import io.grpc.Channel
@ApplicationScoped @ApplicationScoped
class InstanceHeartbeatService: class InstanceHeartbeatService:
@Inject @Inject
private var redissonClient: RedissonClient = uninitialized private var redis: RedisDataSource = uninitialized
@GrpcClient("coordinator-grpc") @GrpcClient("coordinator-grpc")
private var channel: Channel = uninitialized private var channel: Channel = uninitialized
@@ -95,17 +94,15 @@ class InstanceHeartbeatService:
def addGameSubscription(gameId: String): Unit = def addGameSubscription(gameId: String): Unit =
if !coordinatorEnabled then return if !coordinatorEnabled then return
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey) redis.set(classOf[String]).sadd(setKey, gameId)
gameSet.add(gameId)
subscriptionCount += 1 subscriptionCount += 1
def removeGameSubscription(gameId: String): Unit = def removeGameSubscription(gameId: String): Unit =
if !coordinatorEnabled then return if !coordinatorEnabled then return
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey) redis.set(classOf[String]).srem(setKey, gameId)
gameSet.remove(gameId)
subscriptionCount = Math.max(0, subscriptionCount - 1) subscriptionCount = Math.max(0, subscriptionCount - 1)
private def generateInstanceId(): Unit = private def generateInstanceId(): Unit =
@@ -177,8 +174,7 @@ class InstanceHeartbeatService:
private def refreshRedisHeartbeat(): Unit = private def refreshRedisHeartbeat(): Unit =
try try
val key = s"$redisPrefix:instances:$instanceId" val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key)
val metadata = Map( val metadata = Map(
"instanceId" -> instanceId, "instanceId" -> instanceId,
@@ -192,8 +188,7 @@ class InstanceHeartbeatService:
) )
val json = mapper.writeValueAsString(metadata) val json = mapper.writeValueAsString(metadata)
bucket.set(json) redis.value(classOf[String]).setex(key, 5L, json)
(bucket.expire(5, TimeUnit.SECONDS): @nowarn)
catch catch
case ex: Exception => case ex: Exception =>
log.warnf(ex, "Failed to refresh Redis heartbeat") log.warnf(ex, "Failed to refresh Redis heartbeat")
@@ -208,10 +203,10 @@ class InstanceHeartbeatService:
if instanceId.nonEmpty then if instanceId.nonEmpty then
val key = s"$redisPrefix:instances:$instanceId" val key = s"$redisPrefix:instances:$instanceId"
redissonClient.getBucket[String](key).delete() redis.key(classOf[String]).del(key)
val setKey = s"$redisPrefix:instance:$instanceId:games" val setKey = s"$redisPrefix:instance:$instanceId:games"
redissonClient.getSet[String](setKey).delete() redis.key(classOf[String]).del(setKey)
heartbeatExecutor.shutdown() heartbeatExecutor.shutdown()
redisHeartbeatExecutor.shutdown() redisHeartbeatExecutor.shutdown()
@@ -12,6 +12,10 @@ quarkus:
url: http://localhost:8085 url: http://localhost:8085
nowchess: nowchess:
internal:
secret: test-secret
auth:
enabled: false
coordinator: coordinator:
enabled: false enabled: false
redis: redis:
@@ -1,17 +1,17 @@
package de.nowchess.chess.config package de.nowchess.chess.config
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.Priority import jakarta.annotation.Priority
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Alternative import jakarta.enterprise.inject.Alternative
import jakarta.enterprise.inject.Produces import jakarta.enterprise.inject.Produces
import org.mockito.Mockito import org.mockito.Mockito
import org.redisson.api.RedissonClient
@Alternative @Alternative
@Priority(1) @Priority(1)
@ApplicationScoped @ApplicationScoped
class MockRedissonProducer: class MockRedisDataSourceProducer:
@Produces @Produces
@ApplicationScoped @ApplicationScoped
def produceRedissonClient(): RedissonClient = def produceRedisDataSource(): RedisDataSource =
Mockito.mock(classOf[RedissonClient], Mockito.RETURNS_DEEP_STUBS) Mockito.mock(classOf[RedisDataSource], Mockito.RETURNS_DEEP_STUBS)
@@ -40,7 +40,7 @@ class GameResourceIntegrationTest:
@BeforeEach @BeforeEach
def setupMocks(): Unit = def setupMocks(): Unit =
when(jwt.getClaim[AnyRef]("type")).thenReturn("bot") when(jwt.getClaim[AnyRef]("type")).thenReturn("user")
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial) when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) => when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
@@ -0,0 +1,5 @@
nowchess:
internal:
secret: test-secret
auth:
enabled: false
+1 -1
View File
@@ -74,7 +74,7 @@ dependencies {
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}") implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}")) testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -3,6 +3,8 @@ quarkus:
port: 8088 port: 8088
application: application:
name: nowchess-official-bots name: nowchess-official-bots
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
smallrye-jwt: smallrye-jwt:
enabled: true enabled: true
log: log:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -1,35 +0,0 @@
package de.nowchess.bot.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -6,19 +6,19 @@ import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.config.RedisConfig import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.runtime.StartupEvent import io.quarkus.runtime.StartupEvent
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes import jakarta.enterprise.event.Observes
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.util.function.Consumer
@ApplicationScoped @ApplicationScoped
class OfficialBotService: class OfficialBotService:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var botController: BotController = uninitialized
@@ -31,13 +31,8 @@ class OfficialBotService:
BotController.listBots.foreach(subscribeToEventChannel) BotController.listBots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit = private def subscribeToEventChannel(botName: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events") val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
topic.addListener( redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleBotEvent(botName, msg),
)
() ()
private def handleBotEvent(botName: String, msg: String): Unit = private def handleBotEvent(botName: String, msg: String): Unit =
@@ -52,13 +47,8 @@ class OfficialBotService:
catch case _: Exception => () catch case _: Exception => ()
private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit = private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c") val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
topic.addListener( redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
)
() ()
private def handleGameEvent( private def handleGameEvent(
@@ -87,7 +77,7 @@ class OfficialBotService:
val uci = toUci(move) val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s" val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}""" val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
redisson.getTopic(c2sTopic).publish(moveMsg) redis.pubsub(classOf[String]).publish(c2sTopic, moveMsg)
() ()
} }
} }
@@ -12,17 +12,10 @@ import de.nowchess.rules.sets.DefaultRules
class ClassicalBotTest extends AnyFunSuite with Matchers: class ClassicalBotTest extends AnyFunSuite with Matchers:
test("name returns expected format"):
val botEasy = ClassicalBot(BotDifficulty.Easy)
botEasy.name should include("ClassicalBot")
botEasy.name should include("Easy")
val botMedium = ClassicalBot(BotDifficulty.Medium)
botMedium.name should include("Medium")
test("nextMove on initial position returns a move"): test("nextMove on initial position returns a move"):
val bot = ClassicalBot(BotDifficulty.Easy) val bot = ClassicalBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should not be None move should not be None
test("nextMove returns None for position with no legal moves"): test("nextMove returns None for position with no legal moves"):
@@ -39,13 +32,13 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should be(None) move should be(None)
test("all BotDifficulty values work"): test("all BotDifficulty values work"):
BotDifficulty.values.foreach { difficulty => BotDifficulty.values.foreach { difficulty =>
val bot = ClassicalBot(difficulty) val bot = ClassicalBot(difficulty)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
// All difficulties should return a move on the initial position // All difficulties should return a move on the initial position
move should not be None move should not be None
} }
@@ -70,7 +63,7 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should be(Some(moveToReturn)) move should be(Some(moveToReturn))
test("nextMove skips a move repeated three times in a row"): test("nextMove skips a move repeated three times in a row"):
@@ -95,4 +88,4 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
val context = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove)) val context = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = ClassicalBot(BotDifficulty.Easy, stubRules) val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
bot.nextMove(context) should be(None) bot.apply(context) should be(None)
@@ -17,17 +17,12 @@ import scala.util.Using
class HybridBotTest extends AnyFunSuite with Matchers: class HybridBotTest extends AnyFunSuite with Matchers:
test("HybridBot name includes difficulty"): test("HybridBot apply returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
bot.name should include("HybridBot")
bot.name should include("Easy")
test("HybridBot nextMove returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy) val bot = HybridBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should not be None move should not be None
test("HybridBot nextMove returns None when no legal moves"): test("HybridBot apply returns None when no legal moves"):
val noMovesRules = new RuleSet: val noMovesRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
@@ -40,13 +35,13 @@ class HybridBotTest extends AnyFunSuite with Matchers:
def isThreefoldRepetition(context: GameContext): Boolean = false def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = HybridBot(BotDifficulty.Easy, noMovesRules) val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should be(None) move should be(None)
test("HybridBot with empty book falls through to search"): test("HybridBot with empty book falls through to search"):
val emptyBook = PolyglotBook("/nonexistent/book.bin") val emptyBook = PolyglotBook("/nonexistent/book.bin")
val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook)) val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook))
val move = bot.nextMove(GameContext.initial) val move = bot.apply(GameContext.initial)
move should not be None move should not be None
test("HybridBot skips move repeated three times"): test("HybridBot skips move repeated three times"):
@@ -64,7 +59,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context def applyMove(context: GameContext)(move: Move): GameContext = context
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove)) val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules) val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
bot.nextMove(ctx) should be(None) bot.apply(ctx) should be(None)
test("HybridBot uses book move when available"): test("HybridBot uses book move when available"):
val tempFile = Files.createTempFile("hybrid_book", ".bin") val tempFile = Files.createTempFile("hybrid_book", ".bin")
@@ -82,7 +77,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString) val book = PolyglotBook(tempFile.toString)
val bot = HybridBot(BotDifficulty.Easy, book = Some(book)) val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
bot.nextMove(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))) bot.apply(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
finally Files.deleteIfExists(tempFile) finally Files.deleteIfExists(tempFile)
test("HybridBot reports veto when classical and NNUE differ above threshold"): test("HybridBot reports veto when classical and NNUE differ above threshold"):
@@ -119,7 +114,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
vetoReporter = _ => reported.set(true), vetoReporter = _ => reported.set(true),
) )
bot.nextMove(GameContext.initial) should be(Some(forcedMove)) bot.apply(GameContext.initial) should be(Some(forcedMove))
reported.get should be(true) reported.get should be(true)
test("HybridBot default veto reporter prints when threshold is exceeded"): test("HybridBot default veto reporter prints when threshold is exceeded"):
@@ -155,6 +150,6 @@ class HybridBotTest extends AnyFunSuite with Matchers:
) )
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) { val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
bot.nextMove(GameContext.initial) bot.apply(GameContext.initial)
} }
printed should be(Some(forcedMove)) printed should be(Some(forcedMove))
@@ -96,7 +96,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
test("ClassicalBot without book falls back to search"): test("ClassicalBot without book falls back to search"):
val ctx = GameContext.initial val ctx = GameContext.initial
val bot = ClassicalBot(BotDifficulty.Easy) // no book val bot = ClassicalBot(BotDifficulty.Easy) // no book
val move = bot.nextMove(ctx) val move = bot.apply(ctx)
move shouldNot be(None) move shouldNot be(None)
// The move should be legal // The move should be legal
val allLegalMoves = DefaultRules.allLegalMoves(ctx) val allLegalMoves = DefaultRules.allLegalMoves(ctx)
@@ -120,7 +120,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString) val book = PolyglotBook(tempFile.toString)
val botWithBook = ClassicalBot(BotDifficulty.Easy, book = Some(book)) val botWithBook = ClassicalBot(BotDifficulty.Easy, book = Some(book))
val move = botWithBook.nextMove(ctx) val move = botWithBook.apply(ctx)
// Book should return e2-e4 // Book should return e2-e4
move shouldEqual Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())) move shouldEqual Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
@@ -0,0 +1,5 @@
nowchess:
internal:
secret: test-secret
auth:
enabled: false
@@ -12,12 +12,16 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class InternalAuthFilter extends ContainerRequestFilter: class InternalAuthFilter extends ContainerRequestFilter:
@ConfigProperty(name = "nowchess.internal.secret") @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
var secret: String = uninitialized var secret: String = uninitialized
@ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
var authEnabled: Boolean = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
override def filter(ctx: ContainerRequestContext): Unit = override def filter(ctx: ContainerRequestContext): Unit =
val header = ctx.getHeaderString("X-Internal-Secret") if authEnabled then
if header == null || header != secret then val header = ctx.getHeaderString("X-Internal-Secret")
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()) if header == null || header != secret then
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
@@ -12,9 +12,12 @@ class InternalGrpcAuthInterceptor extends ServerInterceptor:
private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER) private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
@ConfigProperty(name = "nowchess.internal.secret") @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
var secret: String = uninitialized var secret: String = uninitialized
@ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
var authEnabled: Boolean = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
override def interceptCall[Req, Resp]( override def interceptCall[Req, Resp](
@@ -23,7 +26,7 @@ class InternalGrpcAuthInterceptor extends ServerInterceptor:
next: ServerCallHandler[Req, Resp], next: ServerCallHandler[Req, Resp],
): ServerCall.Listener[Req] = ): ServerCall.Listener[Req] =
val token = Option(headers.get(secretKey)).getOrElse("") val token = Option(headers.get(secretKey)).getOrElse("")
if token != secret then if authEnabled && token != secret then
call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata()) call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata())
new ServerCall.Listener[Req] {} new ServerCall.Listener[Req] {}
else else
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies {
implementation("io.quarkus:quarkus-jdbc-postgresql") implementation("io.quarkus:quarkus-jdbc-postgresql")
implementation("io.quarkus:quarkus-smallrye-health") implementation("io.quarkus:quarkus-smallrye-health")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -2,6 +2,8 @@ quarkus:
application.name: nowchess-store application.name: nowchess-store
http.port: 8085 http.port: 8085
config.yaml.enabled: true config.yaml.enabled: true
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
datasource: datasource:
db-kind: postgresql db-kind: postgresql
username: ${DB_USER:nowchess} username: ${DB_USER:nowchess}
@@ -6,11 +6,7 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost") // scalafix:off DisableSyntax.var
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -1,30 +0,0 @@
package de.nowchess.store.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
@Inject
var redisConfig: RedisConfig = uninitialized
@Produces
@ApplicationScoped
def redissonClient(): RedissonClient =
val config = new Config()
config
.useSingleServer()
.setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
.setConnectionMinimumIdleSize(1)
.setConnectTimeout(500)
Redisson.create(config)
@PreDestroy
def close(client: RedissonClient): Unit =
client.shutdown()
@@ -2,28 +2,27 @@ package de.nowchess.store.redis
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.store.service.GameWritebackService import de.nowchess.store.service.GameWritebackService
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.util.Try import scala.util.Try
import java.util.function.Consumer
@ApplicationScoped @ApplicationScoped
class GameWritebackStreamListener: class GameWritebackStreamListener:
@Inject @Inject
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
var redisson: RedissonClient = uninitialized var redis: RedisDataSource = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var writebackService: GameWritebackService = uninitialized @Inject var writebackService: GameWritebackService = uninitialized
// scalafix:on // scalafix:on
@PostConstruct @PostConstruct
def startListening(): Unit = def startListening(): Unit =
val topic = redisson.getTopic("game-writeback") val handler: Consumer[String] = json =>
topic.addListener( Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
classOf[String],
(channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
.foreach(writebackService.writeBack) .foreach(writebackService.writeBack)
) redis.pubsub(classOf[String]).subscribe("game-writeback", handler)
()
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies {
implementation("io.quarkus:quarkus-config-yaml") implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-smallrye-health") implementation("io.quarkus:quarkus-smallrye-health")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}") implementation("io.quarkus:quarkus-redis-client")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}")) testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -3,6 +3,8 @@ quarkus:
port: 8084 port: 8084
application: application:
name: nowchess-ws name: nowchess-ws
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
swagger-ui: swagger-ui:
always-include: true always-include: true
path: /swagger-ui path: /swagger-ui
@@ -1,12 +1,8 @@
package de.nowchess.ws.config package de.nowchess.ws.config
import de.nowchess.ws.resource.ConnectionMeta
import io.quarkus.runtime.annotations.RegisterForReflection import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection( @RegisterForReflection(
targets = Array( targets = Array(),
classOf[ConnectionMeta],
),
) )
class NativeReflectionConfig class NativeReflectionConfig
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
class RedisConfig: class RedisConfig:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized var prefix: String = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -1,35 +0,0 @@
package de.nowchess.ws.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -1,7 +1,9 @@
package de.nowchess.ws.resource package de.nowchess.ws.resource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
final case class ConnectionMeta( final case class ConnectionMeta(
gameId: String, gameId: String,
listenerId: Int, subscriber: PubSubCommands.RedisSubscriber,
playerId: Option[String], playerId: Option[String],
) )
@@ -1,22 +1,22 @@
package de.nowchess.ws.resource package de.nowchess.ws.resource
import de.nowchess.ws.config.RedisConfig import de.nowchess.ws.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import io.quarkus.websockets.next.* import io.quarkus.websockets.next.*
import io.smallrye.jwt.auth.principal.JWTParser import io.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.util.Try import scala.util.Try
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/board/game/{gameId}/ws") @WebSocket(path = "/api/board/game/{gameId}/ws")
class GameWebSocketResource: class GameWebSocketResource:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject @Inject
var redisson: RedissonClient = uninitialized var redis: RedisDataSource = uninitialized
@Inject @Inject
var redisConfig: RedisConfig = uninitialized var redisConfig: RedisConfig = uninitialized
@@ -40,18 +40,13 @@ class GameWebSocketResource:
.filter(_.nonEmpty) .filter(_.nonEmpty)
.flatMap(token => Try(jwtParser.parse(token)).toOption) .flatMap(token => Try(jwtParser.parse(token)).toOption)
.map(_.getSubject) .map(_.getSubject)
val topic = redisson.getTopic(s2cTopic(gameId)) val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
val listenerId = topic.addListener( val subscriber = redis.pubsub(classOf[String]).subscribe(s2cTopic(gameId), handler)
classOf[String], connections.put(connection.id(), ConnectionMeta(gameId, subscriber, playerId))
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
)
connections.put(connection.id(), ConnectionMeta(gameId, listenerId, playerId))
val connectedMsg = playerId match val connectedMsg = playerId match
case Some(pid) => s"""{"type":"CONNECTED","gameId":"$gameId","playerId":"$pid"}""" case Some(pid) => s"""{"type":"CONNECTED","gameId":"$gameId","playerId":"$pid"}"""
case None => s"""{"type":"CONNECTED","gameId":"$gameId"}""" case None => s"""{"type":"CONNECTED","gameId":"$gameId"}"""
redisson.getTopic(c2sTopic(gameId)).publish(connectedMsg) redis.pubsub(classOf[String]).publish(c2sTopic(gameId), connectedMsg)
@OnTextMessage @OnTextMessage
def onTextMessage(connection: WebSocketConnection, message: String): Unit = def onTextMessage(connection: WebSocketConnection, message: String): Unit =
@@ -59,13 +54,13 @@ class GameWebSocketResource:
val enriched = meta.playerId match val enriched = meta.playerId match
case Some(pid) => injectPlayerId(message, pid) case Some(pid) => injectPlayerId(message, pid)
case None => message case None => message
redisson.getTopic(c2sTopic(meta.gameId)).publish(enriched) redis.pubsub(classOf[String]).publish(c2sTopic(meta.gameId), enriched)
} }
@OnClose @OnClose
def onClose(connection: WebSocketConnection): Unit = def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { meta => Option(connections.remove(connection.id())).foreach { meta =>
redisson.getTopic(s2cTopic(meta.gameId)).removeListener(meta.listenerId) meta.subscriber.unsubscribe(s2cTopic(meta.gameId))
} }
private def injectPlayerId(msg: String, pid: String): String = private def injectPlayerId(msg: String, pid: String): String =
@@ -1,22 +1,22 @@
package de.nowchess.ws.resource package de.nowchess.ws.resource
import de.nowchess.ws.config.RedisConfig import de.nowchess.ws.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.pubsub.PubSubCommands
import io.quarkus.websockets.next.* import io.quarkus.websockets.next.*
import io.smallrye.jwt.auth.principal.JWTParser import io.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.util.Try import scala.util.Try
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/user/ws") @WebSocket(path = "/api/user/ws")
class UserWebSocketResource: class UserWebSocketResource:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject @Inject
var redisson: RedissonClient = uninitialized var redis: RedisDataSource = uninitialized
@Inject @Inject
var redisConfig: RedisConfig = uninitialized var redisConfig: RedisConfig = uninitialized
@@ -25,7 +25,7 @@ class UserWebSocketResource:
var jwtParser: JWTParser = uninitialized var jwtParser: JWTParser = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val connections = new ConcurrentHashMap[String, (String, Int)]() private val connections = new ConcurrentHashMap[String, (String, PubSubCommands.RedisSubscriber)]()
private def userTopic(userId: String): String = private def userTopic(userId: String): String =
s"${redisConfig.prefix}:user:$userId:events" s"${redisConfig.prefix}:user:$userId:events"
@@ -40,18 +40,14 @@ class UserWebSocketResource:
userIdOpt match userIdOpt match
case None => connection.close().subscribe().`with`(_ => (), _ => ()) case None => connection.close().subscribe().`with`(_ => (), _ => ())
case Some(userId) => case Some(userId) =>
val listenerId = redisson.getTopic(userTopic(userId)).addListener( val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
classOf[String], val subscriber = redis.pubsub(classOf[String]).subscribe(userTopic(userId), handler)
new MessageListener[String]: connections.put(connection.id(), (userId, subscriber))
def onMessage(channel: CharSequence, msg: String): Unit =
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
)
connections.put(connection.id(), (userId, listenerId))
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}""" val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ()) connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
@OnClose @OnClose
def onClose(connection: WebSocketConnection): Unit = def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { (userId, listenerId) => Option(connections.remove(connection.id())).foreach { (userId, subscriber) =>
redisson.getTopic(userTopic(userId)).removeListener(listenerId) subscriber.unsubscribe(userTopic(userId))
} }