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/**",
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
"**/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/**",
// GameWebSocketResource in core — replaced by ws module
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
@@ -99,8 +99,7 @@ val versions = mapOf(
"SCALA_PARSER_COMBINATORS" to "2.4.0",
"FASTPARSE" to "3.0.2",
"JACKSON" to "2.17.2",
"JACKSON_SCALA" to "2.17.2",
"REDISSON" to "3.32.0"
"JACKSON_SCALA" to "2.17.2"
)
extra["VERSIONS"] = versions
+1 -1
View File
@@ -62,7 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi")
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("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
application:
name: nowchess-account
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
core-service:
url: http://localhost:8080
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// 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")
var prefix: String = uninitialized
// 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
import de.nowchess.account.config.RedisConfig
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
@ApplicationScoped
class EventPublisher:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
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"}"""
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 =
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 =
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:
key:
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-openapi")
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"))
@@ -3,6 +3,8 @@ quarkus:
port: 8087
application:
name: nowchess-bot-platform
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
smallrye-jwt:
enabled: true
log:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// 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")
var prefix: String = uninitialized
// 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
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 jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@ApplicationScoped
class BotRegistry:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// 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 =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events")
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
)
connections.put(botId, (emitter, listenerId))
val channel = s"${redisConfig.prefix}:bot:$botId:events"
val handler: Consumer[String] = msg => emitter.asInstanceOf[MultiEmitter[String]].emit(msg)
val subscriber = redis.pubsub(classOf[String]).subscribe(channel, handler)
connections.put(botId, (emitter, subscriber))
()
def unregister(botId: String): Unit =
Option(connections.remove(botId)).foreach { (_, listenerId) =>
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId)
Option(connections.remove(botId)).foreach { (_, subscriber) =>
subscriber.unsubscribe(s"${redisConfig.prefix}:bot:$botId:events")
}
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] =
@@ -2,6 +2,7 @@ package de.nowchess.botplatform.resource
import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotRegistry
import io.quarkus.redis.datasource.RedisDataSource
import io.smallrye.mutiny.Multi
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
@@ -9,9 +10,8 @@ import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.function.Consumer
@Path("/api/bot")
@ApplicationScoped
@@ -21,7 +21,7 @@ class BotEventResource:
// scalafix:off DisableSyntax.var
@Inject var registry: BotRegistry = uninitialized
@Inject var jwt: JsonWebToken = uninitialized
@Inject var redisson: RedissonClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
@@ -44,14 +44,10 @@ class BotEventResource:
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter =>
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
)
emitter.onTermination(() => topic.removeListener(listenerId))
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val handler: Consumer[String] = msg => emitter.emit(msg)
val subscriber = redis.pubsub(classOf[String]).subscribe(topicName, handler)
emitter.onTermination(() => subscriber.unsubscribe(topicName))
}
@POST
@@ -63,5 +59,5 @@ class BotEventResource:
): Response =
val playerId = Option(jwt.getSubject).getOrElse("")
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()
+1 -1
View File
@@ -72,7 +72,7 @@ dependencies {
implementation("io.quarkus:quarkus-rest-client")
implementation("io.quarkus:quarkus-rest-client-jackson")
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")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
@@ -3,6 +3,8 @@ quarkus:
name: nowchess-coordinator
http:
port: 8086
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
grpc:
server:
port: 9086
@@ -4,29 +4,9 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import io.fabric8.kubernetes.client.KubernetesClientBuilder
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
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
@ApplicationScoped
@@ -2,7 +2,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import io.quarkus.redis.datasource.RedisDataSource
import de.nowchess.coordinator.config.CoordinatorConfig
import com.fasterxml.jackson.databind.ObjectMapper
import scala.jdk.CollectionConverters.*
@@ -15,7 +15,7 @@ import de.nowchess.coordinator.grpc.CoreGrpcClient
@ApplicationScoped
class CacheEvictionManager:
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
@Inject
private var config: CoordinatorConfig = uninitialized
@@ -39,7 +39,7 @@ class CacheEvictionManager:
log.info("Starting cache eviction scan")
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 idleThresholdMs = config.gameIdleThreshold.toMillis
@@ -47,8 +47,7 @@ class CacheEvictionManager:
var evictedCount = 0
keys.asScala.foreach { key =>
try
val bucket = redissonClient.getBucket[String](key)
val value = bucket.get()
val value = redis.value(classOf[String]).get(key)
if value != null then
val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
val lastUpdated = extractLastUpdatedTimestamp(value)
@@ -57,7 +56,7 @@ class CacheEvictionManager:
findInstanceWithGame(gameId).foreach { instance =>
try
coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId))
bucket.delete()
redis.key(classOf[String]).del(key)
evictedCount += 1
log.infof("Evicted idle game %s from %s", gameId, instance.instanceId)
catch
@@ -82,9 +81,8 @@ class CacheEvictionManager:
private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] =
try
instanceRegistry.getAllInstances.find { instance =>
val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.contains(gameId)
val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
redis.set(classOf[String]).sismember(setKey, gameId)
}
catch
case ex: Exception =>
@@ -2,7 +2,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.*
import scala.compiletime.uninitialized
import org.jboss.logging.Logger
@@ -12,7 +12,7 @@ import de.nowchess.coordinator.grpc.CoreGrpcClient
@ApplicationScoped
class FailoverService:
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
@Inject
private var instanceRegistry: InstanceRegistry = uninitialized
@@ -50,9 +50,8 @@ class FailoverService:
cleanupDeadInstance(instanceId)
private def getOrphanedGames(instanceId: String): List[String] =
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.readAll.asScala.toList
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).smembers(setKey).asScala.toList
private def distributeGames(
gameIds: List[String],
@@ -87,7 +86,6 @@ class FailoverService:
}
private def cleanupDeadInstance(instanceId: String): Unit =
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.delete()
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.key(classOf[String]).del(setKey)
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 io.fabric8.kubernetes.client.KubernetesClient
import io.fabric8.kubernetes.api.model.Pod
import org.redisson.api.RedissonClient
import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.*
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
@@ -23,7 +23,7 @@ class HealthMonitor:
private var instanceRegistry: InstanceRegistry = uninitialized
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
private val log = Logger.getLogger(classOf[HealthMonitor])
private var redisPrefix = "nowchess"
@@ -47,10 +47,8 @@ class HealthMonitor:
private def checkRedisHeartbeat(instanceId: String): Boolean =
try
val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key)
val ttl = bucket.remainTimeToLive()
ttl > 0
val key = s"$redisPrefix:instances:$instanceId"
redis.key(classOf[String]).pttl(key) > 0
catch
case ex: Exception =>
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.inject.Inject
import org.redisson.api.RedissonClient
import io.quarkus.redis.datasource.RedisDataSource
import scala.jdk.CollectionConverters.*
import scala.compiletime.uninitialized
import com.fasterxml.jackson.databind.ObjectMapper
@@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class InstanceRegistry:
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
private val mapper = ObjectMapper()
private val instances = ConcurrentHashMap[String, InstanceMetadata]()
@@ -28,9 +28,8 @@ class InstanceRegistry:
instances.values.asScala.toList
def updateInstanceFromRedis(instanceId: String): Unit =
val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key)
val value = bucket.get()
val key = s"$redisPrefix:instances:$instanceId"
val value = redis.value(classOf[String]).get(key)
if value != null then
try
val metadata = mapper.readValue(value, classOf[InstanceMetadata])
@@ -3,7 +3,7 @@ package de.nowchess.coordinator.service
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import de.nowchess.coordinator.config.CoordinatorConfig
import org.redisson.api.RedissonClient
import io.quarkus.redis.datasource.RedisDataSource
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.concurrent.duration.*
@@ -19,7 +19,7 @@ class LoadBalancer:
private var instanceRegistry: InstanceRegistry = uninitialized
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
@Inject
private var coreGrpcClient: CoreGrpcClient = uninitialized
@@ -107,9 +107,8 @@ class LoadBalancer:
private def getGamesToMove(instanceId: String, count: Int): List[String] =
try
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.readAll.asScala.toList.take(count)
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).smembers(setKey).asScala.toList.take(count)
catch
case ex: Exception =>
log.debugf(ex, "Failed to get games for %s", instanceId)
@@ -120,12 +119,9 @@ class LoadBalancer:
val fromKey = s"$redisPrefix:instance:$fromInstanceId:games"
val toKey = s"$redisPrefix:instance:$toInstanceId:games"
val fromSet = redissonClient.getSet[String](fromKey)
val toSet = redissonClient.getSet[String](toKey)
gameIds.foreach { gameId =>
fromSet.remove(gameId)
toSet.add(gameId)
redis.set(classOf[String]).srem(fromKey, gameId)
redis.set(classOf[String]).sadd(toKey, gameId)
}
catch
case ex: Exception =>
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies {
implementation("io.quarkus:quarkus-websockets-next")
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:rule"))
@@ -3,6 +3,8 @@ quarkus:
port: 8080
application:
name: nowchess-core
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
grpc:
clients:
rule-grpc:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// 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")
var prefix: String = uninitialized
// 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.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper
import org.redisson.api.RTopic
import io.quarkus.redis.datasource.RedisDataSource
class GameRedisPublisher(
gameId: String,
registry: GameRegistry,
redisson: org.redisson.api.RedissonClient,
redis: RedisDataSource,
objectMapper: ObjectMapper,
s2cTopicName: String,
writebackEmit: String => Unit,
@@ -26,7 +26,7 @@ class GameRedisPublisher(
registry.get(gameId).foreach { entry =>
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
redisson.getTopic(s2cTopicName).publish(json)
redis.pubsub(classOf[String]).publish(s2cTopicName, json)
val clock = entry.engine.currentClockState
val wb = GameWritebackEventDto(
@@ -10,20 +10,21 @@ import de.nowchess.chess.observer.Observer
import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper
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.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@ApplicationScoped
class GameRedisSubscriberManager:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var registry: GameRegistry = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@@ -31,7 +32,7 @@ class GameRedisSubscriberManager:
@Inject var heartbeatService: InstanceHeartbeatService = null
// 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 def c2sTopic(gameId: String): String =
@@ -42,21 +43,15 @@ class GameRedisSubscriberManager:
def subscribeGame(gameId: String): Unit =
try
val topic = redisson.getTopic(c2sTopic(gameId))
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleC2sMessage(gameId, msg),
)
c2sListeners.put(gameId, listenerId)
val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
val subscriber = redis.pubsub(classOf[String]).subscribe(c2sTopic(gameId), handler)
c2sListeners.put(gameId, subscriber)
val writebackTopic = redisson.getTopic("game-writeback")
val writebackFn: String => Unit = json => writebackTopic.publish(json)
val writebackFn: String => Unit = json => redis.pubsub(classOf[String]).publish("game-writeback", json)
val obs = new GameRedisPublisher(
gameId,
registry,
redisson,
redis,
objectMapper,
s2cTopicName(gameId),
writebackFn,
@@ -73,8 +68,8 @@ class GameRedisSubscriberManager:
()
def unsubscribeGame(gameId: String): Unit =
Option(c2sListeners.remove(gameId)).foreach { listenerId =>
redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
Option(c2sListeners.remove(gameId)).foreach { subscriber =>
subscriber.unsubscribe(c2sTopic(gameId))
}
Option(s2cObservers.remove(gameId)).foreach { obs =>
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
@@ -84,16 +79,16 @@ class GameRedisSubscriberManager:
private def handleC2sMessage(gameId: String, msg: String): Unit =
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.Ping) => ()
case None => ()
case Some(C2sMessage.Ping) => ()
case None => ()
private def handleConnected(gameId: String): Unit =
registry.get(gameId).foreach { entry =>
val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
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 =
@@ -120,8 +115,8 @@ class GameRedisSubscriberManager:
val pid = Option(node.get("playerId")).map(_.asText()).filter(_.nonEmpty)
C2sMessage.Move(u.asText(), pid)
}
case "PING" => Some(C2sMessage.Ping)
case _ => None
case "PING" => Some(C2sMessage.Ping)
case _ => None
}
}
@@ -157,5 +152,5 @@ class GameRedisSubscriberManager:
@PreDestroy
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)))
@@ -12,23 +12,22 @@ import de.nowchess.chess.grpc.RuleSetGrpcAdapter
import de.nowchess.chess.config.RedisConfig
import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.chess.resource.GameDtoMapper
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.redisson.api.RedissonClient
import scala.annotation.nowarn
import scala.compiletime.uninitialized
import scala.util.Try
import java.nio.charset.StandardCharsets
import java.security.{MessageDigest, SecureRandom}
import java.time.Instant
import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class RedisGameRegistry extends GameRegistry:
@Inject
// scalafix:off DisableSyntax.var
var redisson: RedissonClient = uninitialized
var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
@@ -40,7 +39,6 @@ class RedisGameRegistry extends GameRegistry:
private val rng = new SecureRandom()
private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@@ -49,9 +47,7 @@ class RedisGameRegistry extends GameRegistry:
def store(entry: GameEntry): Unit =
localEngines.put(entry.gameId, entry)
val combined = ioClient.exportCombined(entry.engine.context)
val b = bucket(entry.gameId)
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
def get(gameId: String): Option[GameEntry] =
Option(localEngines.get(gameId)) match
@@ -66,12 +62,10 @@ class RedisGameRegistry extends GameRegistry:
def update(entry: GameEntry): Unit =
localEngines.put(entry.gameId, entry)
val combined = ioClient.exportCombined(entry.engine.context)
val b = bucket(entry.gameId)
b.set(toJson(entry, combined.fen, combined.pgn))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
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
}
@@ -111,9 +105,7 @@ class RedisGameRegistry extends GameRegistry:
}.toOption
.map { case (dto, entry) =>
localEngines.put(gameId, entry)
val b = bucket(gameId)
b.set(objectMapper.writeValueAsString(dto))
(b.expire(30, TimeUnit.MINUTES): @nowarn)
redis.value(classOf[String]).setex(cacheKey(gameId), 1800L, objectMapper.writeValueAsString(dto))
entry
}
@@ -7,8 +7,7 @@ import io.quarkus.runtime.StartupEvent
import io.quarkus.runtime.ShutdownEvent
import io.quarkus.grpc.GrpcClient
import org.eclipse.microprofile.config.inject.ConfigProperty
import org.redisson.api.RedissonClient
import scala.annotation.nowarn
import io.quarkus.redis.datasource.RedisDataSource
import scala.compiletime.uninitialized
import java.util.concurrent.{Executors, TimeUnit}
import java.net.InetAddress
@@ -22,7 +21,7 @@ import io.grpc.Channel
@ApplicationScoped
class InstanceHeartbeatService:
@Inject
private var redissonClient: RedissonClient = uninitialized
private var redis: RedisDataSource = uninitialized
@GrpcClient("coordinator-grpc")
private var channel: Channel = uninitialized
@@ -95,17 +94,15 @@ class InstanceHeartbeatService:
def addGameSubscription(gameId: String): Unit =
if !coordinatorEnabled then return
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.add(gameId)
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).sadd(setKey, gameId)
subscriptionCount += 1
def removeGameSubscription(gameId: String): Unit =
if !coordinatorEnabled then return
val setKey = s"$redisPrefix:instance:$instanceId:games"
val gameSet = redissonClient.getSet[String](setKey)
gameSet.remove(gameId)
val setKey = s"$redisPrefix:instance:$instanceId:games"
redis.set(classOf[String]).srem(setKey, gameId)
subscriptionCount = Math.max(0, subscriptionCount - 1)
private def generateInstanceId(): Unit =
@@ -177,8 +174,7 @@ class InstanceHeartbeatService:
private def refreshRedisHeartbeat(): Unit =
try
val key = s"$redisPrefix:instances:$instanceId"
val bucket = redissonClient.getBucket[String](key)
val key = s"$redisPrefix:instances:$instanceId"
val metadata = Map(
"instanceId" -> instanceId,
@@ -192,8 +188,7 @@ class InstanceHeartbeatService:
)
val json = mapper.writeValueAsString(metadata)
bucket.set(json)
(bucket.expire(5, TimeUnit.SECONDS): @nowarn)
redis.value(classOf[String]).setex(key, 5L, json)
catch
case ex: Exception =>
log.warnf(ex, "Failed to refresh Redis heartbeat")
@@ -208,10 +203,10 @@ class InstanceHeartbeatService:
if instanceId.nonEmpty then
val key = s"$redisPrefix:instances:$instanceId"
redissonClient.getBucket[String](key).delete()
redis.key(classOf[String]).del(key)
val setKey = s"$redisPrefix:instance:$instanceId:games"
redissonClient.getSet[String](setKey).delete()
redis.key(classOf[String]).del(setKey)
heartbeatExecutor.shutdown()
redisHeartbeatExecutor.shutdown()
@@ -12,6 +12,10 @@ quarkus:
url: http://localhost:8085
nowchess:
internal:
secret: test-secret
auth:
enabled: false
coordinator:
enabled: false
redis:
@@ -1,17 +1,17 @@
package de.nowchess.chess.config
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.Priority
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Alternative
import jakarta.enterprise.inject.Produces
import org.mockito.Mockito
import org.redisson.api.RedissonClient
@Alternative
@Priority(1)
@ApplicationScoped
class MockRedissonProducer:
class MockRedisDataSourceProducer:
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
Mockito.mock(classOf[RedissonClient], Mockito.RETURNS_DEEP_STUBS)
def produceRedisDataSource(): RedisDataSource =
Mockito.mock(classOf[RedisDataSource], Mockito.RETURNS_DEEP_STUBS)
@@ -40,7 +40,7 @@ class GameResourceIntegrationTest:
@BeforeEach
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.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:rule"))
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("org.junit.jupiter:junit-jupiter")
@@ -3,6 +3,8 @@ quarkus:
port: 8088
application:
name: nowchess-official-bots
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
smallrye-jwt:
enabled: true
log:
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// 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")
var prefix: String = uninitialized
// 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.config.RedisConfig
import de.nowchess.io.fen.FenParser
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.runtime.StartupEvent
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.function.Consumer
@ApplicationScoped
class OfficialBotService:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized
@@ -31,13 +31,8 @@ class OfficialBotService:
BotController.listBots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events")
topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleBotEvent(botName, msg),
)
val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
()
private def handleBotEvent(botName: String, msg: String): Unit =
@@ -52,13 +47,8 @@ class OfficialBotService:
catch case _: Exception => ()
private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
)
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
()
private def handleGameEvent(
@@ -87,7 +77,7 @@ class OfficialBotService:
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
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:
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"):
val bot = ClassicalBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
val move = bot.apply(GameContext.initial)
move should not be None
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
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
val move = bot.apply(GameContext.initial)
move should be(None)
test("all BotDifficulty values work"):
BotDifficulty.values.foreach { 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
move should not be None
}
@@ -70,7 +63,7 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
val move = bot.nextMove(GameContext.initial)
val move = bot.apply(GameContext.initial)
move should be(Some(moveToReturn))
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 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:
test("HybridBot name includes difficulty"):
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"):
test("HybridBot apply returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
val move = bot.nextMove(GameContext.initial)
val move = bot.apply(GameContext.initial)
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:
def candidateMoves(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 applyMove(context: GameContext)(move: Move): GameContext = context
val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
val move = bot.nextMove(GameContext.initial)
val move = bot.apply(GameContext.initial)
move should be(None)
test("HybridBot with empty book falls through to search"):
val emptyBook = PolyglotBook("/nonexistent/book.bin")
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
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
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
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"):
val tempFile = Files.createTempFile("hybrid_book", ".bin")
@@ -82,7 +77,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString)
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)
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),
)
bot.nextMove(GameContext.initial) should be(Some(forcedMove))
bot.apply(GameContext.initial) should be(Some(forcedMove))
reported.get should be(true)
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()) {
bot.nextMove(GameContext.initial)
bot.apply(GameContext.initial)
}
printed should be(Some(forcedMove))
@@ -96,7 +96,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
test("ClassicalBot without book falls back to search"):
val ctx = GameContext.initial
val bot = ClassicalBot(BotDifficulty.Easy) // no book
val move = bot.nextMove(ctx)
val move = bot.apply(ctx)
move shouldNot be(None)
// The move should be legal
val allLegalMoves = DefaultRules.allLegalMoves(ctx)
@@ -120,7 +120,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString)
val botWithBook = ClassicalBot(BotDifficulty.Easy, book = Some(book))
val move = botWithBook.nextMove(ctx)
val move = botWithBook.apply(ctx)
// Book should return e2-e4
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
class InternalAuthFilter extends ContainerRequestFilter:
@ConfigProperty(name = "nowchess.internal.secret")
@ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
@ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
var authEnabled: Boolean = uninitialized
// scalafix:on DisableSyntax.var
override def filter(ctx: ContainerRequestContext): Unit =
val header = ctx.getHeaderString("X-Internal-Secret")
if header == null || header != secret then
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
if authEnabled then
val header = ctx.getHeaderString("X-Internal-Secret")
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)
@ConfigProperty(name = "nowchess.internal.secret")
@ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
@ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
var authEnabled: Boolean = uninitialized
// scalafix:on DisableSyntax.var
override def interceptCall[Req, Resp](
@@ -23,7 +26,7 @@ class InternalGrpcAuthInterceptor extends ServerInterceptor:
next: ServerCallHandler[Req, Resp],
): ServerCall.Listener[Req] =
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())
new ServerCall.Listener[Req] {}
else
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies {
implementation("io.quarkus:quarkus-jdbc-postgresql")
implementation("io.quarkus:quarkus-smallrye-health")
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("org.junit.jupiter:junit-jupiter")
@@ -2,6 +2,8 @@ quarkus:
application.name: nowchess-store
http.port: 8085
config.yaml.enabled: true
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
datasource:
db-kind: postgresql
username: ${DB_USER:nowchess}
@@ -6,11 +6,7 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
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 de.nowchess.store.service.GameWritebackService
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.function.Consumer
@ApplicationScoped
class GameWritebackStreamListener:
@Inject
// scalafix:off DisableSyntax.var
var redisson: RedissonClient = uninitialized
var redis: RedisDataSource = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var writebackService: GameWritebackService = uninitialized
// scalafix:on
@PostConstruct
def startListening(): Unit =
val topic = redisson.getTopic("game-writeback")
topic.addListener(
classOf[String],
(channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
val handler: Consumer[String] = json =>
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
.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-smallrye-health")
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("org.junit.jupiter:junit-jupiter")
@@ -3,6 +3,8 @@ quarkus:
port: 8084
application:
name: nowchess-ws
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
swagger-ui:
always-include: true
path: /swagger-ui
@@ -1,12 +1,8 @@
package de.nowchess.ws.config
import de.nowchess.ws.resource.ConnectionMeta
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[ConnectionMeta],
),
targets = Array(),
)
class NativeReflectionConfig
@@ -7,12 +7,6 @@ import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// 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")
var prefix: String = uninitialized
// 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
import io.quarkus.redis.datasource.pubsub.PubSubCommands
final case class ConnectionMeta(
gameId: String,
listenerId: Int,
subscriber: PubSubCommands.RedisSubscriber,
playerId: Option[String],
)
@@ -1,22 +1,22 @@
package de.nowchess.ws.resource
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.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/board/game/{gameId}/ws")
class GameWebSocketResource:
// scalafix:off DisableSyntax.var
@Inject
var redisson: RedissonClient = uninitialized
var redis: RedisDataSource = uninitialized
@Inject
var redisConfig: RedisConfig = uninitialized
@@ -40,18 +40,13 @@ class GameWebSocketResource:
.filter(_.nonEmpty)
.flatMap(token => Try(jwtParser.parse(token)).toOption)
.map(_.getSubject)
val topic = redisson.getTopic(s2cTopic(gameId))
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
)
connections.put(connection.id(), ConnectionMeta(gameId, listenerId, playerId))
val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
val subscriber = redis.pubsub(classOf[String]).subscribe(s2cTopic(gameId), handler)
connections.put(connection.id(), ConnectionMeta(gameId, subscriber, playerId))
val connectedMsg = playerId match
case Some(pid) => s"""{"type":"CONNECTED","gameId":"$gameId","playerId":"$pid"}"""
case None => s"""{"type":"CONNECTED","gameId":"$gameId"}"""
redisson.getTopic(c2sTopic(gameId)).publish(connectedMsg)
redis.pubsub(classOf[String]).publish(c2sTopic(gameId), connectedMsg)
@OnTextMessage
def onTextMessage(connection: WebSocketConnection, message: String): Unit =
@@ -59,13 +54,13 @@ class GameWebSocketResource:
val enriched = meta.playerId match
case Some(pid) => injectPlayerId(message, pid)
case None => message
redisson.getTopic(c2sTopic(meta.gameId)).publish(enriched)
redis.pubsub(classOf[String]).publish(c2sTopic(meta.gameId), enriched)
}
@OnClose
def onClose(connection: WebSocketConnection): Unit =
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 =
@@ -1,22 +1,22 @@
package de.nowchess.ws.resource
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.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@WebSocket(path = "/api/user/ws")
class UserWebSocketResource:
// scalafix:off DisableSyntax.var
@Inject
var redisson: RedissonClient = uninitialized
var redis: RedisDataSource = uninitialized
@Inject
var redisConfig: RedisConfig = uninitialized
@@ -25,7 +25,7 @@ class UserWebSocketResource:
var jwtParser: JWTParser = uninitialized
// 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 =
s"${redisConfig.prefix}:user:$userId:events"
@@ -40,18 +40,14 @@ class UserWebSocketResource:
userIdOpt match
case None => connection.close().subscribe().`with`(_ => (), _ => ())
case Some(userId) =>
val listenerId = redisson.getTopic(userTopic(userId)).addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
)
connections.put(connection.id(), (userId, listenerId))
val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
val subscriber = redis.pubsub(classOf[String]).subscribe(userTopic(userId), handler)
connections.put(connection.id(), (userId, subscriber))
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
@OnClose
def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { (userId, listenerId) =>
redisson.getTopic(userTopic(userId)).removeListener(listenerId)
Option(connections.remove(connection.id())).foreach { (userId, subscriber) =>
subscriber.unsubscribe(userTopic(userId))
}