feat(redis): migrate from Redisson to Quarkus Redis client and update configuration
This commit is contained in:
+2
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
-35
@@ -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())
|
||||
+12
-16
@@ -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] =
|
||||
|
||||
+8
-12
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-9
@@ -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 =>
|
||||
|
||||
+6
-8
@@ -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)
|
||||
|
||||
+4
-6
@@ -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)
|
||||
|
||||
+4
-5
@@ -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])
|
||||
|
||||
+6
-10
@@ -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 =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+10
-15
@@ -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:
|
||||
|
||||
+4
-4
@@ -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)
|
||||
+1
-1
@@ -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
|
||||
@@ -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())
|
||||
+8
-18
@@ -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())
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+7
-8
@@ -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)
|
||||
()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user