Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f30f0bfe | |||
| 1f4e9c8498 |
@@ -94,3 +94,24 @@
|
||||
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
|
||||
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||
* **tournament:** sync native-server participants and route start ([#78](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/78)) ([1f4e9c8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1f4e9c8498f55d95ab48758df60c7618445bf6ca))
|
||||
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.time.Instant
|
||||
@Entity
|
||||
@Table(name = "tournaments")
|
||||
class Tournament:
|
||||
// scalafix:off DisableSyntax.var
|
||||
// scalafix:off
|
||||
@Id
|
||||
var id: String = uninitialized
|
||||
|
||||
@@ -33,4 +33,7 @@ class Tournament:
|
||||
|
||||
@Column(nullable = true)
|
||||
var originServerUrl: String = null
|
||||
|
||||
@Column(nullable = true)
|
||||
var nativeTournamentId: String = null
|
||||
// scalafix:on
|
||||
|
||||
+70
-26
@@ -52,9 +52,9 @@ class TournamentResource:
|
||||
@PermitAll
|
||||
def list(): Response =
|
||||
val (created, started, finished) = tournamentService.list()
|
||||
val internalCreated = created.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalStarted = started.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalFinished = finished.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalCreated = created.map(nativeOverlay)
|
||||
val internalStarted = started.map(nativeOverlay)
|
||||
val internalFinished = finished.map(nativeOverlay)
|
||||
|
||||
val (extCreated, extStarted, extFinished) = registry
|
||||
.serverUrls()
|
||||
@@ -96,7 +96,7 @@ class TournamentResource:
|
||||
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
||||
val t = tournamentService.create(userId, form)
|
||||
selfUrl.ifPresent { url =>
|
||||
registry.serverUrls().foreach { remoteUrl =>
|
||||
registry.serverUrls().filterNot(externalClient.isNativeServer).foreach { remoteUrl =>
|
||||
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
|
||||
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
||||
}
|
||||
@@ -115,8 +115,42 @@ class TournamentResource:
|
||||
"rated" -> t.rated.toString,
|
||||
),
|
||||
)
|
||||
if !externalClient.publishNative(nativeServerUrl, directorName, form) then
|
||||
log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl)
|
||||
externalClient.publishNative(nativeServerUrl, directorName, form) match
|
||||
case Some(nativeId) => tournamentService.setNativeTournamentId(t.id, nativeId)
|
||||
case None => log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl)
|
||||
|
||||
// Resolve the native-server twin of a local tournament. Backfills the stored id by matching
|
||||
// fullName against the native list for tournaments created before the id was captured.
|
||||
private def nativeIdFor(t: de.nowchess.tournament.domain.Tournament): Option[String] =
|
||||
if nativeServerUrl.isEmpty then None
|
||||
else
|
||||
Option(t.nativeTournamentId).filter(_.nonEmpty).orElse {
|
||||
val found = externalClient
|
||||
.fetchList(nativeServerUrl)
|
||||
.flatMap { node =>
|
||||
Seq("created", "started", "finished").iterator
|
||||
.flatMap(k => node.path(k).elements().asScala)
|
||||
.find(_.path("fullName").asText() == t.fullName)
|
||||
.map(_.path("id").asText())
|
||||
.filter(_.nonEmpty)
|
||||
}
|
||||
found.foreach(id => tournamentService.setNativeTournamentId(t.id, id))
|
||||
found
|
||||
}
|
||||
|
||||
// Overlay live participant/standings/status fields from the native twin onto a local DTO so
|
||||
// bots that joined directly on the native server are reflected in NowChess.
|
||||
private def nativeOverlay(t: de.nowchess.tournament.domain.Tournament): JsonNode =
|
||||
val standings = tournamentService.getStandings(t.id)
|
||||
val dto = objectMapper.valueToTree[JsonNode](tournamentService.toDto(t, standings))
|
||||
nativeIdFor(t).flatMap(nid => externalClient.fetch(nativeServerUrl, nid)) match
|
||||
case Some(native) =>
|
||||
val merged = dto.deepCopy[com.fasterxml.jackson.databind.node.ObjectNode]()
|
||||
Seq("nbPlayers", "standing", "status", "round", "winner").foreach { field =>
|
||||
if native.has(field) then merged.set(field, native.get(field))
|
||||
}
|
||||
merged
|
||||
case None => dto
|
||||
|
||||
private def encodeForm(params: Map[String, String]): String =
|
||||
params
|
||||
@@ -132,8 +166,7 @@ class TournamentResource:
|
||||
def get(@PathParam("id") id: String): Response =
|
||||
tournamentService.get(id) match
|
||||
case Some(t) =>
|
||||
val standings = tournamentService.getStandings(id)
|
||||
Response.ok(tournamentService.toDto(t, standings)).build()
|
||||
Response.ok(nativeOverlay(t)).build()
|
||||
case None =>
|
||||
resolveServer(id)
|
||||
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
|
||||
@@ -179,19 +212,26 @@ class TournamentResource:
|
||||
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
case None =>
|
||||
tournamentService.start(id, userId) match
|
||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||
case Left(error) =>
|
||||
error match
|
||||
case TournamentError.NotFound(_) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
resolveServer(id)
|
||||
.map { url =>
|
||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
}
|
||||
.getOrElse(errorResponse(error))
|
||||
case _ => errorResponse(error)
|
||||
tournamentService.get(id).flatMap(nativeIdFor) match
|
||||
case Some(nativeId) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
val (status, body) = externalClient.proxyPost(nativeServerUrl, s"api/tournament/$nativeId/start", auth)
|
||||
if status / 100 == 2 then tournamentService.markStatus(id, "started")
|
||||
Response.status(status).entity(body).build()
|
||||
case None =>
|
||||
tournamentService.start(id, userId) match
|
||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||
case Left(error) =>
|
||||
error match
|
||||
case TournamentError.NotFound(_) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
resolveServer(id)
|
||||
.map { url =>
|
||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
}
|
||||
.getOrElse(errorResponse(error))
|
||||
case _ => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/join")
|
||||
@@ -317,7 +357,8 @@ class TournamentResource:
|
||||
tournamentService.get(id) match
|
||||
case Some(t) if Option(t.originServerUrl).isDefined =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
externalClient.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
|
||||
externalClient
|
||||
.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
|
||||
.map { inputStream =>
|
||||
Response
|
||||
.ok(new StreamingOutput {
|
||||
@@ -334,10 +375,12 @@ class TournamentResource:
|
||||
.`type`("application/x-ndjson")
|
||||
.build()
|
||||
}
|
||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build())
|
||||
.getOrElse(
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build(),
|
||||
)
|
||||
case Some(_) =>
|
||||
val botId = Option(jwt.getSubject).getOrElse("")
|
||||
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
|
||||
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
|
||||
val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] {
|
||||
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
|
||||
queue.put(Some(item)); this
|
||||
@@ -358,7 +401,7 @@ class TournamentResource:
|
||||
var cont = true
|
||||
while cont do
|
||||
queue.take() match
|
||||
case None => cont = false
|
||||
case None => cont = false
|
||||
case Some(line) =>
|
||||
output.write((line + "\n").getBytes("UTF-8"))
|
||||
output.flush()
|
||||
@@ -440,7 +483,8 @@ class TournamentResource:
|
||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).build())
|
||||
|
||||
private def resolveServer(tournamentId: String): Option[String] =
|
||||
tournamentService.get(tournamentId)
|
||||
tournamentService
|
||||
.get(tournamentId)
|
||||
.flatMap(t => Option(t.originServerUrl))
|
||||
.orElse(registry.findServerUrl(tournamentId))
|
||||
.orElse {
|
||||
|
||||
+15
-12
@@ -14,7 +14,7 @@ import scala.util.Try
|
||||
class ExternalTournamentClient:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@volatile private var directorToken: Option[String] = None
|
||||
|
||||
@ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086")
|
||||
@@ -30,7 +30,7 @@ class ExternalTournamentClient:
|
||||
// RS256 user token to it — swap in the director token registered on that server.
|
||||
private def normalize(url: String): String = url.stripSuffix("/")
|
||||
|
||||
private def isNativeServer(serverUrl: String): Boolean =
|
||||
def isNativeServer(serverUrl: String): Boolean =
|
||||
nativeServerUrl.nonEmpty && normalize(serverUrl) == normalize(nativeServerUrl)
|
||||
|
||||
private def directorBearer(): Option[String] =
|
||||
@@ -45,18 +45,18 @@ class ExternalTournamentClient:
|
||||
private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] =
|
||||
if isNativeServer(serverUrl) then directorBearer() else userAuth
|
||||
|
||||
def publishNative(serverUrl: String, directorName: String, form: String): Boolean =
|
||||
def publishNative(serverUrl: String, directorName: String, form: String): Option[String] =
|
||||
val token = directorToken.orElse {
|
||||
val fresh = registerDirector(serverUrl, directorName)
|
||||
directorToken = fresh
|
||||
fresh
|
||||
}
|
||||
token.exists { tok =>
|
||||
if createNative(serverUrl, tok, form) then true
|
||||
else
|
||||
token.flatMap { tok =>
|
||||
createNative(serverUrl, tok, form).orElse {
|
||||
val refreshed = registerDirector(serverUrl, directorName)
|
||||
directorToken = refreshed
|
||||
refreshed.exists(createNative(serverUrl, _, form))
|
||||
refreshed.flatMap(createNative(serverUrl, _, form))
|
||||
}
|
||||
}
|
||||
|
||||
private def registerDirector(serverUrl: String, name: String): Option[String] =
|
||||
@@ -76,7 +76,7 @@ class ExternalTournamentClient:
|
||||
client.close()
|
||||
}.getOrElse(None)
|
||||
|
||||
private def createNative(serverUrl: String, token: String, form: String): Boolean =
|
||||
private def createNative(serverUrl: String, token: String, form: String): Option[String] =
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val response = client
|
||||
@@ -84,11 +84,14 @@ class ExternalTournamentClient:
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED))
|
||||
try response.getStatus / 100 == 2
|
||||
try
|
||||
if response.getStatus / 100 == 2 then
|
||||
Option(objectMapper.readTree(response.readEntity(classOf[String])).path("id").asText()).filter(_.nonEmpty)
|
||||
else None
|
||||
finally
|
||||
response.close()
|
||||
client.close()
|
||||
}.getOrElse(false)
|
||||
}.getOrElse(None)
|
||||
|
||||
def fetchList(serverUrl: String): Option[JsonNode] =
|
||||
Try {
|
||||
@@ -141,8 +144,8 @@ class ExternalTournamentClient:
|
||||
|
||||
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val body = objectMapper.writeValueAsString(req)
|
||||
val client = buildClient()
|
||||
val body = objectMapper.writeValueAsString(req)
|
||||
val response = client
|
||||
.target(s"$serverUrl/api/tournament/replicate")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
|
||||
+8
@@ -82,6 +82,14 @@ class TournamentService:
|
||||
def get(id: String): Option[Tournament] =
|
||||
tournamentRepository.findOptById(id)
|
||||
|
||||
@Transactional
|
||||
def setNativeTournamentId(id: String, nativeId: String): Unit =
|
||||
tournamentRepository.findOptById(id).foreach(_.nativeTournamentId = nativeId)
|
||||
|
||||
@Transactional
|
||||
def markStatus(id: String, status: String): Unit =
|
||||
tournamentRepository.findOptById(id).foreach(_.status = status)
|
||||
|
||||
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
|
||||
(
|
||||
tournamentRepository.findByStatus("created"),
|
||||
|
||||
@@ -30,3 +30,7 @@ nowchess:
|
||||
secret: test-secret
|
||||
auth:
|
||||
enabled: false
|
||||
tournament:
|
||||
self-url: ""
|
||||
external-servers: ""
|
||||
native-server-url: "http://localhost:1"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=7
|
||||
MINOR=8
|
||||
PATCH=0
|
||||
|
||||
Reference in New Issue
Block a user