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