feat(events): migrate game-creation and bot flows to Redis Streams NCS-89 (#62)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Replace synchronous account→core game-creation HTTP call and plain pub/sub bot game-start events with Redis Streams using consumer groups, XACK, retry, and a Dead Letter Queue for at-least-once delivery and observability. - account: GameCreationStreamClient publishes game-creation requests and correlates responses via a per-instance consumer group (NCS-91) - core: GameCreationStreamListener consumes requests, calls GameCreationService, publishes response events, retries, and routes exhausted/unparseable events to the DLQ (NCS-91, NCS-93, NCS-94) - official-bots: bot game-start events migrated from pub/sub to Streams with consumer group, XACK, retry, and DLQ (NCS-92) - account EventPublisher dual-writes to the stream and legacy pub/sub channel for backward compatibility - all flows use the typed EventEnvelope (eventId/type/payload/timestamp/ correlationId) with DLQ error context (eventType, error, attempt) - register new DTOs and EventEnvelope/EventType for native reflection Closes NCS-91, NCS-92, NCS-93, NCS-94 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #62
This commit was merged in pull request #62.
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
package de.nowchess.account.client
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.account.config.RedisConfig
|
||||
import de.nowchess.api.dto.{GameCreationRequestDto, GameCreationResponseDto, PlayerInfoDto, TimeControlDto}
|
||||
import de.nowchess.api.game.GameMode
|
||||
import de.nowchess.api.player.PlayerType
|
||||
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||
import io.quarkus.runtime.StartupEvent
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.event.Observes
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import org.eclipse.microprofile.context.ManagedExecutor
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.{CompletableFuture, ConcurrentHashMap, TimeUnit}
|
||||
|
||||
@ApplicationScoped
|
||||
class GameCreationStreamClient:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var executor: ManagedExecutor = uninitialized
|
||||
@ConfigProperty(name = "nowchess.game-creation-stream.enabled", defaultValue = "true")
|
||||
private var streamEnabled: Boolean = true
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val log = Logger.getLogger(classOf[GameCreationStreamClient])
|
||||
private val instanceId = UUID.randomUUID().toString
|
||||
private val groupName = s"account-game-creation-$instanceId"
|
||||
private val consumerId = instanceId
|
||||
private val maxStreamLen = 1000L
|
||||
private val timeout = Duration.ofSeconds(10)
|
||||
|
||||
private val pending = new ConcurrentHashMap[String, CompletableFuture[GameCreationResponseDto]]()
|
||||
|
||||
private def requestStream: String = s"${redisConfig.prefix}:game-creation"
|
||||
private def responseStream: String = s"${redisConfig.prefix}:game-creation-response"
|
||||
|
||||
def start(@Observes _ev: StartupEvent): Unit =
|
||||
if streamEnabled then
|
||||
createGroupIfAbsent()
|
||||
executor.submit(
|
||||
new Runnable:
|
||||
def run(): Unit = pollLoop(),
|
||||
)
|
||||
log.infof("Game-creation response listener started (consumer=%s)", consumerId)
|
||||
|
||||
def createGame(req: CoreCreateGameRequest): GameCreationResponseDto =
|
||||
val correlationId = UUID.randomUUID().toString
|
||||
val future = new CompletableFuture[GameCreationResponseDto]()
|
||||
pending.put(correlationId, future)
|
||||
Try {
|
||||
val payload = objectMapper.valueToTree[com.fasterxml.jackson.databind.JsonNode](toDto(req))
|
||||
val envelope = EventEnvelope.of(EventType.GameCreationRequest, payload, Some(correlationId))
|
||||
publish(requestStream, envelope)
|
||||
future.get(timeout.toMillis, TimeUnit.MILLISECONDS)
|
||||
} match
|
||||
case Success(resp) =>
|
||||
pending.remove(correlationId)
|
||||
resp
|
||||
case Failure(ex) =>
|
||||
pending.remove(correlationId)
|
||||
log.errorf(ex, "Game creation request %s failed", correlationId)
|
||||
GameCreationResponseDto(None, Some("Game creation request timed out or failed"))
|
||||
|
||||
private def toDto(req: CoreCreateGameRequest): GameCreationRequestDto =
|
||||
GameCreationRequestDto(
|
||||
white = req.white.map(p => PlayerInfoDto(p.id, p.displayName, PlayerType.Human)),
|
||||
black = req.black.map(p => PlayerInfoDto(p.id, p.displayName, PlayerType.Human)),
|
||||
timeControl = req.timeControl.map(t => TimeControlDto(t.limitSeconds, t.incrementSeconds, t.daysPerMove)),
|
||||
mode = req.mode.map(_ => GameMode.Authenticated),
|
||||
)
|
||||
|
||||
private def createGroupIfAbsent(): Unit =
|
||||
Try(
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xgroupCreate(responseStream, groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||
) match
|
||||
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||
case Failure(ex) => log.warnf(ex, "Failed to create response consumer group")
|
||||
case Success(_) => ()
|
||||
|
||||
private def pollLoop(): Unit =
|
||||
while true do
|
||||
Try {
|
||||
val messages = redis
|
||||
.stream(classOf[String])
|
||||
.xreadgroup(
|
||||
groupName,
|
||||
consumerId,
|
||||
responseStream,
|
||||
">",
|
||||
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||
)
|
||||
Option(messages).foreach(_.forEach(handleResponse))
|
||||
} match
|
||||
case Failure(ex) => log.warnf(ex, "Error in game-creation response poll loop")
|
||||
case Success(_) => ()
|
||||
|
||||
private def handleResponse(msg: StreamMessage[String, String, String]): Unit =
|
||||
val json = msg.payload().get("data")
|
||||
Try(objectMapper.readValue(json, classOf[EventEnvelope])) match
|
||||
case Success(envelope) =>
|
||||
envelope.correlationId.flatMap(id => Option(pending.remove(id))).foreach { future =>
|
||||
Try(objectMapper.treeToValue(envelope.payload, classOf[GameCreationResponseDto])) match
|
||||
case Success(resp) => future.complete(resp)
|
||||
case Failure(ex) => future.completeExceptionally(ex)
|
||||
}
|
||||
case Failure(ex) => log.warnf(ex, "Unparseable game-creation response: %s", json)
|
||||
ack(msg.id())
|
||||
|
||||
private def ack(id: String): Unit =
|
||||
Try(redis.stream(classOf[String]).xack(responseStream, groupName, id)) match
|
||||
case Failure(ex) => log.warnf(ex, "Failed to ack response %s", id)
|
||||
case Success(_) => ()
|
||||
|
||||
private def publish(key: String, envelope: EventEnvelope): Unit =
|
||||
val json = objectMapper.writeValueAsString(envelope)
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), Map("data" -> json).asJava)
|
||||
()
|
||||
+10
@@ -12,6 +12,12 @@ import de.nowchess.account.domain.{
|
||||
UserAccount,
|
||||
}
|
||||
import de.nowchess.account.dto.*
|
||||
import de.nowchess.api.dto.{
|
||||
GameCreationRequestDto,
|
||||
GameCreationResponseDto,
|
||||
PlayerInfoDto as ApiPlayerInfoDto,
|
||||
TimeControlDto as ApiTimeControlDto,
|
||||
}
|
||||
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@@ -49,6 +55,10 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[CoreCreateGameRequest],
|
||||
classOf[CoreGameResponse],
|
||||
classOf[OfficialChallengeResponse],
|
||||
classOf[GameCreationRequestDto],
|
||||
classOf[GameCreationResponseDto],
|
||||
classOf[ApiPlayerInfoDto],
|
||||
classOf[ApiTimeControlDto],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
|
||||
+3
-5
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CorePlayerInfo, GameCreationStreamClient}
|
||||
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
||||
import de.nowchess.account.service.{AccountService, EventPublisher}
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
@@ -9,7 +9,6 @@ import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@@ -29,8 +28,7 @@ class OfficialChallengeResource:
|
||||
@Inject var botEventPublisher: EventPublisher = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
var gameCreationClient: GameCreationStreamClient = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
|
||||
@@ -72,7 +70,7 @@ class OfficialChallengeResource:
|
||||
(CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
|
||||
val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
|
||||
val gameId =
|
||||
try Right(coreGameClient.createGame(req).gameId)
|
||||
try gameCreationClient.createGame(req).gameId.toRight("Failed to create game")
|
||||
catch case _ => Left("Failed to create game")
|
||||
gameId match
|
||||
case Left(err) =>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.client.{
|
||||
CoreCreateGameRequest,
|
||||
CoreGameClient,
|
||||
CoreGameResponse,
|
||||
CorePlayerInfo,
|
||||
CoreTimeControl,
|
||||
}
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CorePlayerInfo, CoreTimeControl, GameCreationStreamClient}
|
||||
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
|
||||
import de.nowchess.account.dto.{
|
||||
ChallengeDto,
|
||||
@@ -23,7 +17,6 @@ import jakarta.annotation.PostConstruct
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.transaction.Transactional
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@@ -45,8 +38,7 @@ class ChallengeService:
|
||||
var challengeRepository: ChallengeRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
var gameCreationClient: GameCreationStreamClient = uninitialized
|
||||
|
||||
@Inject
|
||||
var eventPublisher: EventPublisher = uninitialized
|
||||
@@ -187,7 +179,7 @@ class ChallengeService:
|
||||
val (white, black) = assignColors(challenge)
|
||||
val tc = buildTimeControl(challenge)
|
||||
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
|
||||
Right(coreGameClient.createGame(req).gameId)
|
||||
gameCreationClient.createGame(req).gameId.toRight(ChallengeError.GameCreationFailed)
|
||||
catch case _ => Left(ChallengeError.GameCreationFailed)
|
||||
|
||||
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.account.config.RedisConfig
|
||||
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.stream.XAddArgs
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class EventPublisher:
|
||||
@@ -17,13 +19,25 @@ class EventPublisher:
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val maxStreamLen = 1000L
|
||||
|
||||
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||
val payload = objectMapper.createObjectNode()
|
||||
payload.put("gameId", gameId)
|
||||
payload.put("playingAs", playingAs)
|
||||
payload.put("difficulty", difficulty)
|
||||
payload.put("botAccountId", botAccountId)
|
||||
publish(s"${redisConfig.prefix}:bot:$botId:events", EventType.GameStart, payload)
|
||||
val envelope = EventEnvelope.of(EventType.BotGameStart, payload)
|
||||
val json = objectMapper.writeValueAsString(envelope)
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xadd(
|
||||
s"${redisConfig.prefix}:bot:$botId:events:stream",
|
||||
new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(),
|
||||
Map("data" -> json).asJava,
|
||||
)
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", json)
|
||||
()
|
||||
|
||||
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
|
||||
val payload = objectMapper.createObjectNode()
|
||||
|
||||
@@ -34,3 +34,5 @@ nowchess:
|
||||
secret: test-secret
|
||||
auth:
|
||||
enabled: false
|
||||
game-creation-stream:
|
||||
enabled: false
|
||||
|
||||
+6
-5
@@ -1,11 +1,11 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
|
||||
import de.nowchess.account.client.GameCreationStreamClient
|
||||
import de.nowchess.api.dto.GameCreationResponseDto
|
||||
import io.quarkus.test.InjectMock
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||
import org.mockito.{ArgumentMatchers, Mockito}
|
||||
@@ -14,14 +14,15 @@ import org.mockito.{ArgumentMatchers, Mockito}
|
||||
class ChallengeResourceTest:
|
||||
|
||||
@InjectMock
|
||||
@RestClient
|
||||
// scalafix:off DisableSyntax.var
|
||||
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||
var gameCreationClient: GameCreationStreamClient = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@BeforeEach
|
||||
def setup(): Unit =
|
||||
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
|
||||
Mockito
|
||||
.when(gameCreationClient.createGame(ArgumentMatchers.any()))
|
||||
.thenReturn(GameCreationResponseDto(Some("test-game-id")))
|
||||
|
||||
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
import de.nowchess.api.game.GameMode
|
||||
|
||||
final case class GameCreationRequestDto(
|
||||
white: Option[PlayerInfoDto],
|
||||
black: Option[PlayerInfoDto],
|
||||
timeControl: Option[TimeControlDto],
|
||||
mode: Option[GameMode] = None,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
final case class GameCreationResponseDto(
|
||||
gameId: Option[String],
|
||||
error: Option[String] = None,
|
||||
)
|
||||
@@ -138,6 +138,8 @@ tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||
exclude("**/resource/GameDtoMapper.scala")
|
||||
exclude("**/resource/GameResource.scala")
|
||||
exclude("**/redis/GameRedis*.scala")
|
||||
exclude("**/redis/GameCreationStreamListener.scala")
|
||||
exclude("**/service/GameCreationService.scala")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.nowchess.chess.config
|
||||
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.chess.registry.GameCacheDto
|
||||
@@ -13,6 +14,10 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[GameCacheDto],
|
||||
classOf[ClockDto],
|
||||
classOf[CreateGameRequestDto],
|
||||
classOf[GameCreationRequestDto],
|
||||
classOf[GameCreationResponseDto],
|
||||
classOf[EventEnvelope],
|
||||
classOf[EventType],
|
||||
classOf[ErrorEventDto],
|
||||
classOf[GameWritebackEventDto],
|
||||
classOf[GameFullDto],
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.nowchess.chess.redis
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.dto.{GameCreationRequestDto, GameCreationResponseDto}
|
||||
import de.nowchess.api.event.{EventEnvelope, EventType}
|
||||
import de.nowchess.chess.config.RedisConfig
|
||||
import de.nowchess.chess.service.GameCreationService
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||
import io.quarkus.runtime.StartupEvent
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.event.Observes
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import org.eclipse.microprofile.context.ManagedExecutor
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
@ApplicationScoped
|
||||
class GameCreationStreamListener:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var creationService: GameCreationService = uninitialized
|
||||
@Inject var executor: ManagedExecutor = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@ConfigProperty(name = "nowchess.game-creation-stream.enabled", defaultValue = "true")
|
||||
private var streamEnabled: Boolean = true
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val log = Logger.getLogger(classOf[GameCreationStreamListener])
|
||||
private val groupName = "core-game-creation"
|
||||
private val consumerId = UUID.randomUUID().toString
|
||||
private val maxRetries = 3
|
||||
private val maxStreamLen = 1000L
|
||||
|
||||
private def requestStream: String = s"${redisConfig.prefix}:game-creation"
|
||||
private def responseStream: String = s"${redisConfig.prefix}:game-creation-response"
|
||||
private def dlqStream: String = s"${redisConfig.prefix}:dlq"
|
||||
|
||||
def start(@Observes _ev: StartupEvent): Unit =
|
||||
if streamEnabled then
|
||||
createGroupIfAbsent()
|
||||
executor.submit(
|
||||
new Runnable:
|
||||
def run(): Unit = pollLoop(),
|
||||
)
|
||||
log.infof("Game-creation request listener started (consumer=%s)", consumerId)
|
||||
|
||||
private def createGroupIfAbsent(): Unit =
|
||||
Try(
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xgroupCreate(requestStream, groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||
) match
|
||||
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||
case Failure(ex) => log.warnf(ex, "Failed to create game-creation consumer group")
|
||||
case Success(_) => ()
|
||||
|
||||
private def pollLoop(): Unit =
|
||||
while true do
|
||||
Try {
|
||||
val messages = redis
|
||||
.stream(classOf[String])
|
||||
.xreadgroup(
|
||||
groupName,
|
||||
consumerId,
|
||||
requestStream,
|
||||
">",
|
||||
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||
)
|
||||
Option(messages).foreach(_.forEach(handleMessage))
|
||||
} match
|
||||
case Failure(ex) => log.warnf(ex, "Error in game-creation poll loop")
|
||||
case Success(_) => ()
|
||||
|
||||
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||
val json = msg.payload().get("data")
|
||||
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||
Try(objectMapper.readValue(json, classOf[EventEnvelope])) match
|
||||
case Failure(ex) =>
|
||||
log.errorf(ex, "Unparseable game-creation event, sending to DLQ: %s", json)
|
||||
toDlq(EventType.GameCreationRequest.toString, json, ex, attempt)
|
||||
ack(msg.id())
|
||||
case Success(envelope) =>
|
||||
processEnvelope(msg, envelope, json, attempt)
|
||||
|
||||
private def processEnvelope(
|
||||
msg: StreamMessage[String, String, String],
|
||||
envelope: EventEnvelope,
|
||||
json: String,
|
||||
attempt: Int,
|
||||
): Unit =
|
||||
Try {
|
||||
val req = objectMapper.treeToValue(envelope.payload, classOf[GameCreationRequestDto])
|
||||
val entry = creationService.createGame(req)
|
||||
publishResponse(envelope.correlationId, GameCreationResponseDto(Some(entry.gameId)))
|
||||
} match
|
||||
case Success(_) => ack(msg.id())
|
||||
case Failure(ex) if attempt + 1 < maxRetries =>
|
||||
log.warnf(ex, "Game creation failed (attempt %d), retrying", attempt)
|
||||
retry(json, attempt + 1)
|
||||
ack(msg.id())
|
||||
case Failure(ex) =>
|
||||
log.errorf(ex, "Game creation failed after %d attempts, sending to DLQ", maxRetries)
|
||||
publishResponse(envelope.correlationId, GameCreationResponseDto(None, Some("Game creation failed")))
|
||||
toDlq(envelope.`type`.toString, json, ex, attempt)
|
||||
ack(msg.id())
|
||||
|
||||
private def publishResponse(correlationId: Option[String], resp: GameCreationResponseDto): Unit =
|
||||
val payload = objectMapper.valueToTree[com.fasterxml.jackson.databind.JsonNode](resp)
|
||||
val envelope = EventEnvelope.of(EventType.GameCreationResponse, payload, correlationId)
|
||||
xadd(responseStream, Map("data" -> objectMapper.writeValueAsString(envelope)))
|
||||
|
||||
private def retry(json: String, attempt: Int): Unit =
|
||||
xadd(requestStream, Map("data" -> json, "attempt" -> attempt.toString))
|
||||
|
||||
private def toDlq(eventType: String, json: String, error: Throwable, attempt: Int): Unit =
|
||||
xadd(
|
||||
dlqStream,
|
||||
Map(
|
||||
"data" -> json,
|
||||
"eventType" -> eventType,
|
||||
"error" -> Option(error.getMessage).getOrElse(error.getClass.getName),
|
||||
"attempt" -> (attempt + 1).toString,
|
||||
),
|
||||
)
|
||||
|
||||
private def ack(id: String): Unit =
|
||||
Try(redis.stream(classOf[String]).xack(requestStream, groupName, id)) match
|
||||
case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id)
|
||||
case Success(_) => ()
|
||||
|
||||
private def xadd(key: String, fields: Map[String, String]): Unit =
|
||||
Try(
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), fields.asJava),
|
||||
) match
|
||||
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
|
||||
case Success(_) => ()
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.nowchess.chess.service
|
||||
|
||||
import de.nowchess.api.dto.{GameCreationRequestDto, PlayerInfoDto, TimeControlDto}
|
||||
import de.nowchess.api.game.{GameContext, GameMode, TimeControl}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.grpc.RuleSetGrpcAdapter
|
||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class GameCreationService:
|
||||
|
||||
private val log = Logger.getLogger(classOf[GameCreationService])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var registry: GameRegistry = uninitialized
|
||||
@Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
|
||||
@Inject var subscriberManager: GameRedisSubscriberManager = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
|
||||
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
|
||||
|
||||
def createGame(req: GameCreationRequestDto): GameEntry =
|
||||
val white = playerInfoFrom(req.white, DefaultWhite)
|
||||
val black = playerInfoFrom(req.black, DefaultBlack)
|
||||
val tc = toTimeControl(req.timeControl)
|
||||
val mode = req.mode.getOrElse(GameMode.Open)
|
||||
val entry = newEntry(GameContext.initial, white, black, tc, mode)
|
||||
registry.store(entry)
|
||||
subscriberManager.subscribeGame(entry.gameId)
|
||||
log.infof(
|
||||
"Game %s created — white=%s black=%s mode=%s",
|
||||
entry.gameId,
|
||||
white.displayName,
|
||||
black.displayName,
|
||||
mode.toString,
|
||||
)
|
||||
entry
|
||||
|
||||
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
|
||||
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||
|
||||
private def toTimeControl(dto: Option[TimeControlDto]): TimeControl =
|
||||
dto match
|
||||
case None => TimeControl.Unlimited
|
||||
case Some(tc) =>
|
||||
tc.daysPerMove match
|
||||
case Some(d) => TimeControl.Correspondence(d)
|
||||
case None =>
|
||||
tc.limitSeconds.fold(TimeControl.Unlimited)(l => TimeControl.Clock(l, tc.incrementSeconds.getOrElse(0)))
|
||||
|
||||
private def newEntry(
|
||||
ctx: GameContext,
|
||||
white: PlayerInfo,
|
||||
black: PlayerInfo,
|
||||
tc: TimeControl,
|
||||
mode: GameMode,
|
||||
): GameEntry =
|
||||
GameEntry(
|
||||
registry.generateId(),
|
||||
GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter, timeControl = tc),
|
||||
white,
|
||||
black,
|
||||
mode = mode,
|
||||
)
|
||||
@@ -18,6 +18,8 @@ nowchess:
|
||||
enabled: false
|
||||
coordinator:
|
||||
enabled: false
|
||||
game-creation-stream:
|
||||
enabled: false
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.nowchess.chess.service
|
||||
|
||||
import de.nowchess.api.dto.{GameCreationRequestDto, PlayerInfoDto, TimeControlDto}
|
||||
import de.nowchess.api.game.{GameMode, TimeControl}
|
||||
import de.nowchess.api.player.PlayerType
|
||||
import de.nowchess.chess.client.CombinedExportResponse
|
||||
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||
import io.quarkus.test.InjectMock
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import jakarta.inject.Inject
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mockito.{verify, when}
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
// scalafix:off
|
||||
@QuarkusTest
|
||||
@DisplayName("GameCreationService")
|
||||
class GameCreationServiceTest:
|
||||
|
||||
@Inject
|
||||
var service: GameCreationService = uninitialized
|
||||
|
||||
@InjectMock
|
||||
var subscriberManager: GameRedisSubscriberManager = uninitialized
|
||||
|
||||
@InjectMock
|
||||
var ioWrapper: IoGrpcClientWrapper = uninitialized
|
||||
|
||||
@BeforeEach
|
||||
def setup(): Unit =
|
||||
when(ioWrapper.exportCombined(any()))
|
||||
.thenReturn(CombinedExportResponse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", ""))
|
||||
|
||||
private def player(id: String, name: String): PlayerInfoDto =
|
||||
PlayerInfoDto(id, name, PlayerType.Human)
|
||||
|
||||
@Test
|
||||
def createsGameAndSubscribes(): Unit =
|
||||
val req =
|
||||
GameCreationRequestDto(Some(player("w", "White")), Some(player("b", "Black")), None, Some(GameMode.Authenticated))
|
||||
val entry = service.createGame(req)
|
||||
assertNotNull(entry.gameId)
|
||||
assertEquals("White", entry.white.displayName)
|
||||
assertEquals("Black", entry.black.displayName)
|
||||
assertEquals(GameMode.Authenticated, entry.mode)
|
||||
verify(subscriberManager).subscribeGame(entry.gameId)
|
||||
|
||||
@Test
|
||||
def defaultsToOpenModeAndDefaultPlayers(): Unit =
|
||||
val entry = service.createGame(GameCreationRequestDto(None, None, None, None))
|
||||
assertEquals(GameMode.Open, entry.mode)
|
||||
assertEquals("Player 1", entry.white.displayName)
|
||||
assertEquals("Player 2", entry.black.displayName)
|
||||
|
||||
@Test
|
||||
def mapsClockTimeControl(): Unit =
|
||||
val tc = TimeControlDto(Some(300), Some(5), None)
|
||||
val entry = service.createGame(GameCreationRequestDto(None, None, Some(tc), None))
|
||||
assertEquals(TimeControl.Clock(300, 5), entry.engine.timeControl)
|
||||
|
||||
@Test
|
||||
def mapsCorrespondenceTimeControl(): Unit =
|
||||
val tc = TimeControlDto(None, None, Some(3))
|
||||
val entry = service.createGame(GameCreationRequestDto(None, None, Some(tc), None))
|
||||
assertEquals(TimeControl.Correspondence(3), entry.engine.timeControl)
|
||||
+101
-13
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.bot.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.event.EventEnvelope
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.bot.BotController
|
||||
import de.nowchess.bot.BotDifficulty
|
||||
@@ -9,14 +10,20 @@ import de.nowchess.bot.config.RedisConfig
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.stream.{StreamMessage, XAddArgs, XGroupCreateArgs, XReadGroupArgs}
|
||||
import io.quarkus.runtime.StartupEvent
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.event.Observes
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.context.ManagedExecutor
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.function.Consumer
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -31,6 +38,7 @@ class OfficialBotService:
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var botController: BotController = uninitialized
|
||||
@Inject var meterRegistry: MeterRegistry = uninitialized
|
||||
@Inject var executor: ManagedExecutor = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
@@ -40,6 +48,14 @@ class OfficialBotService:
|
||||
private val terminalStatuses =
|
||||
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||
|
||||
private val groupName = "official-bot"
|
||||
private val consumerId = UUID.randomUUID().toString
|
||||
private val maxRetries = 3
|
||||
private val maxStreamLen = 1000L
|
||||
|
||||
private def eventStream(botName: String): String = s"${redisConfig.prefix}:bot:$botName:events:stream"
|
||||
private def dlqStream: String = s"${redisConfig.prefix}:dlq"
|
||||
|
||||
@PostConstruct
|
||||
def initializeMetrics(): Unit =
|
||||
BotController.listBots.foreach { bot =>
|
||||
@@ -54,20 +70,92 @@ class OfficialBotService:
|
||||
bots.foreach(subscribeToEventChannel)
|
||||
|
||||
private def subscribeToEventChannel(botName: String): Unit =
|
||||
val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
|
||||
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
|
||||
()
|
||||
createGroupIfAbsent(botName)
|
||||
executor.submit(
|
||||
new Runnable:
|
||||
def run(): Unit = pollLoop(botName),
|
||||
)
|
||||
log.infof("Listening to bot event stream for %s (consumer=%s)", botName, consumerId)
|
||||
|
||||
private def handleBotEvent(botName: String, msg: String): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
if node.path("type").asText() == "gameStart" then
|
||||
val gameId = node.path("gameId").asText()
|
||||
val playingAs = node.path("playingAs").asText()
|
||||
val difficulty = node.path("difficulty").asInt(1400)
|
||||
val botAccountId = node.path("botAccountId").asText()
|
||||
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
||||
catch case _: Exception => ()
|
||||
private def createGroupIfAbsent(botName: String): Unit =
|
||||
Try(
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xgroupCreate(eventStream(botName), groupName, "0", new XGroupCreateArgs().mkstream()),
|
||||
) match
|
||||
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||
case Failure(ex) => log.warnf(ex, "Failed to create bot event consumer group for %s", botName)
|
||||
case Success(_) => ()
|
||||
|
||||
private def pollLoop(botName: String): Unit =
|
||||
while true do
|
||||
Try {
|
||||
val messages = redis
|
||||
.stream(classOf[String])
|
||||
.xreadgroup(
|
||||
groupName,
|
||||
consumerId,
|
||||
eventStream(botName),
|
||||
">",
|
||||
new XReadGroupArgs().count(10).block(Duration.ofSeconds(2)),
|
||||
)
|
||||
Option(messages).foreach(_.forEach(msg => handleStreamMessage(botName, msg)))
|
||||
} match
|
||||
case Failure(ex) => log.warnf(ex, "Error in bot event poll loop for %s", botName)
|
||||
case Success(_) => ()
|
||||
|
||||
private def handleStreamMessage(botName: String, msg: StreamMessage[String, String, String]): Unit =
|
||||
val json = msg.payload().get("data")
|
||||
val attempt = Option(msg.payload().get("attempt")).flatMap(_.toIntOption).getOrElse(0)
|
||||
Try {
|
||||
val envelope = objectMapper.readValue(json, classOf[EventEnvelope])
|
||||
handleBotEvent(botName, envelope)
|
||||
} match
|
||||
case Success(_) => ack(botName, msg.id())
|
||||
case Failure(ex) if attempt + 1 < maxRetries =>
|
||||
log.warnf(ex, "Bot event handling failed for %s (attempt %d), retrying", botName, attempt)
|
||||
retry(botName, json, attempt + 1)
|
||||
ack(botName, msg.id())
|
||||
case Failure(ex) =>
|
||||
log.errorf(ex, "Bot event handling failed for %s after %d attempts, sending to DLQ", botName, maxRetries)
|
||||
toDlq(json, ex, attempt)
|
||||
ack(botName, msg.id())
|
||||
|
||||
private def handleBotEvent(botName: String, envelope: EventEnvelope): Unit =
|
||||
val payload = envelope.payload
|
||||
val gameId = payload.path("gameId").asText()
|
||||
val playingAs = payload.path("playingAs").asText()
|
||||
val difficulty = payload.path("difficulty").asInt(1400)
|
||||
val botAccountId = payload.path("botAccountId").asText()
|
||||
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
||||
|
||||
private def ack(botName: String, id: String): Unit =
|
||||
Try(redis.stream(classOf[String]).xack(eventStream(botName), groupName, id)) match
|
||||
case Failure(ex) => log.warnf(ex, "Failed to ack bot event %s", id)
|
||||
case Success(_) => ()
|
||||
|
||||
private def retry(botName: String, json: String, attempt: Int): Unit =
|
||||
xadd(eventStream(botName), Map("data" -> json, "attempt" -> attempt.toString))
|
||||
|
||||
private def toDlq(json: String, error: Throwable, attempt: Int): Unit =
|
||||
xadd(
|
||||
dlqStream,
|
||||
Map(
|
||||
"data" -> json,
|
||||
"eventType" -> "BotGameStart",
|
||||
"error" -> Option(error.getMessage).getOrElse(error.getClass.getName),
|
||||
"attempt" -> (attempt + 1).toString,
|
||||
),
|
||||
)
|
||||
|
||||
private def xadd(key: String, fields: Map[String, String]): Unit =
|
||||
Try(
|
||||
redis
|
||||
.stream(classOf[String])
|
||||
.xadd(key, new XAddArgs().maxlen(maxStreamLen).nearlyExactTrimming(), fields.asJava),
|
||||
) match
|
||||
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
|
||||
case Success(_) => ()
|
||||
|
||||
private def watchGame(
|
||||
botName: String,
|
||||
|
||||
Reference in New Issue
Block a user