fix(tournament): sync native-server participants and route start (#78)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Bots joining a published tournament directly on the native server were not reflected in NowChess (0 players) and the tournament could not be started, because create() kept a local copy plus a separate native copy whose id was discarded — leaving the two records disconnected. - Capture the native tournament id: createNative/publishNative now return the id instead of Boolean; persist it on Tournament.nativeTournamentId. - Reverse-sync on read: get()/list() overlay nbPlayers/standing/status/round/ winner from the native twin (with a fullName backfill for tournaments created before the id was captured). - start(): proxy to the native twin (director token via authFor) so the native participants are used; mirror the started status locally. - Skip the native server in the replicate loop (it has no /replicate endpoint), removing the per-create "Failed to replicate" warning. - Isolate native integration in tournament unit tests (native-server-url no longer defaults to the live server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #78
This commit was merged in pull request #78.
This commit is contained in:
@@ -7,7 +7,7 @@ import java.time.Instant
|
|||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tournaments")
|
@Table(name = "tournaments")
|
||||||
class Tournament:
|
class Tournament:
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off
|
||||||
@Id
|
@Id
|
||||||
var id: String = uninitialized
|
var id: String = uninitialized
|
||||||
|
|
||||||
@@ -33,4 +33,7 @@ class Tournament:
|
|||||||
|
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originServerUrl: String = null
|
var originServerUrl: String = null
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
var nativeTournamentId: String = null
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|||||||
+70
-26
@@ -52,9 +52,9 @@ class TournamentResource:
|
|||||||
@PermitAll
|
@PermitAll
|
||||||
def list(): Response =
|
def list(): Response =
|
||||||
val (created, started, finished) = tournamentService.list()
|
val (created, started, finished) = tournamentService.list()
|
||||||
val internalCreated = created.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
val internalCreated = created.map(nativeOverlay)
|
||||||
val internalStarted = started.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
val internalStarted = started.map(nativeOverlay)
|
||||||
val internalFinished = finished.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
val internalFinished = finished.map(nativeOverlay)
|
||||||
|
|
||||||
val (extCreated, extStarted, extFinished) = registry
|
val (extCreated, extStarted, extFinished) = registry
|
||||||
.serverUrls()
|
.serverUrls()
|
||||||
@@ -96,7 +96,7 @@ class TournamentResource:
|
|||||||
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
||||||
val t = tournamentService.create(userId, form)
|
val t = tournamentService.create(userId, form)
|
||||||
selfUrl.ifPresent { url =>
|
selfUrl.ifPresent { url =>
|
||||||
registry.serverUrls().foreach { remoteUrl =>
|
registry.serverUrls().filterNot(externalClient.isNativeServer).foreach { remoteUrl =>
|
||||||
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
|
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
|
||||||
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
||||||
}
|
}
|
||||||
@@ -115,8 +115,42 @@ class TournamentResource:
|
|||||||
"rated" -> t.rated.toString,
|
"rated" -> t.rated.toString,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if !externalClient.publishNative(nativeServerUrl, directorName, form) then
|
externalClient.publishNative(nativeServerUrl, directorName, form) match
|
||||||
log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl)
|
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 =
|
private def encodeForm(params: Map[String, String]): String =
|
||||||
params
|
params
|
||||||
@@ -132,8 +166,7 @@ class TournamentResource:
|
|||||||
def get(@PathParam("id") id: String): Response =
|
def get(@PathParam("id") id: String): Response =
|
||||||
tournamentService.get(id) match
|
tournamentService.get(id) match
|
||||||
case Some(t) =>
|
case Some(t) =>
|
||||||
val standings = tournamentService.getStandings(id)
|
Response.ok(nativeOverlay(t)).build()
|
||||||
Response.ok(tournamentService.toDto(t, standings)).build()
|
|
||||||
case None =>
|
case None =>
|
||||||
resolveServer(id)
|
resolveServer(id)
|
||||||
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
|
.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)
|
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth)
|
||||||
Response.status(status).entity(body).build()
|
Response.status(status).entity(body).build()
|
||||||
case None =>
|
case None =>
|
||||||
tournamentService.start(id, userId) match
|
tournamentService.get(id).flatMap(nativeIdFor) match
|
||||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
case Some(nativeId) =>
|
||||||
case Left(error) =>
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
error match
|
val (status, body) = externalClient.proxyPost(nativeServerUrl, s"api/tournament/$nativeId/start", auth)
|
||||||
case TournamentError.NotFound(_) =>
|
if status / 100 == 2 then tournamentService.markStatus(id, "started")
|
||||||
val auth = Option(headers.getHeaderString("Authorization"))
|
Response.status(status).entity(body).build()
|
||||||
resolveServer(id)
|
case None =>
|
||||||
.map { url =>
|
tournamentService.start(id, userId) match
|
||||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||||
Response.status(status).entity(body).build()
|
case Left(error) =>
|
||||||
}
|
error match
|
||||||
.getOrElse(errorResponse(error))
|
case TournamentError.NotFound(_) =>
|
||||||
case _ => errorResponse(error)
|
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
|
@POST
|
||||||
@Path("/{id}/join")
|
@Path("/{id}/join")
|
||||||
@@ -317,7 +357,8 @@ class TournamentResource:
|
|||||||
tournamentService.get(id) match
|
tournamentService.get(id) match
|
||||||
case Some(t) if Option(t.originServerUrl).isDefined =>
|
case Some(t) if Option(t.originServerUrl).isDefined =>
|
||||||
val auth = Option(headers.getHeaderString("Authorization"))
|
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 =>
|
.map { inputStream =>
|
||||||
Response
|
Response
|
||||||
.ok(new StreamingOutput {
|
.ok(new StreamingOutput {
|
||||||
@@ -334,10 +375,12 @@ class TournamentResource:
|
|||||||
.`type`("application/x-ndjson")
|
.`type`("application/x-ndjson")
|
||||||
.build()
|
.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(_) =>
|
case Some(_) =>
|
||||||
val botId = Option(jwt.getSubject).getOrElse("")
|
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] {
|
val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] {
|
||||||
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
|
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
|
||||||
queue.put(Some(item)); this
|
queue.put(Some(item)); this
|
||||||
@@ -358,7 +401,7 @@ class TournamentResource:
|
|||||||
var cont = true
|
var cont = true
|
||||||
while cont do
|
while cont do
|
||||||
queue.take() match
|
queue.take() match
|
||||||
case None => cont = false
|
case None => cont = false
|
||||||
case Some(line) =>
|
case Some(line) =>
|
||||||
output.write((line + "\n").getBytes("UTF-8"))
|
output.write((line + "\n").getBytes("UTF-8"))
|
||||||
output.flush()
|
output.flush()
|
||||||
@@ -440,7 +483,8 @@ class TournamentResource:
|
|||||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).build())
|
.getOrElse(Response.status(Response.Status.NOT_FOUND).build())
|
||||||
|
|
||||||
private def resolveServer(tournamentId: String): Option[String] =
|
private def resolveServer(tournamentId: String): Option[String] =
|
||||||
tournamentService.get(tournamentId)
|
tournamentService
|
||||||
|
.get(tournamentId)
|
||||||
.flatMap(t => Option(t.originServerUrl))
|
.flatMap(t => Option(t.originServerUrl))
|
||||||
.orElse(registry.findServerUrl(tournamentId))
|
.orElse(registry.findServerUrl(tournamentId))
|
||||||
.orElse {
|
.orElse {
|
||||||
|
|||||||
+15
-12
@@ -14,7 +14,7 @@ import scala.util.Try
|
|||||||
class ExternalTournamentClient:
|
class ExternalTournamentClient:
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@volatile private var directorToken: Option[String] = None
|
@volatile private var directorToken: Option[String] = None
|
||||||
|
|
||||||
@ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086")
|
@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.
|
// RS256 user token to it — swap in the director token registered on that server.
|
||||||
private def normalize(url: String): String = url.stripSuffix("/")
|
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)
|
nativeServerUrl.nonEmpty && normalize(serverUrl) == normalize(nativeServerUrl)
|
||||||
|
|
||||||
private def directorBearer(): Option[String] =
|
private def directorBearer(): Option[String] =
|
||||||
@@ -45,18 +45,18 @@ class ExternalTournamentClient:
|
|||||||
private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] =
|
private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] =
|
||||||
if isNativeServer(serverUrl) then directorBearer() else userAuth
|
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 token = directorToken.orElse {
|
||||||
val fresh = registerDirector(serverUrl, directorName)
|
val fresh = registerDirector(serverUrl, directorName)
|
||||||
directorToken = fresh
|
directorToken = fresh
|
||||||
fresh
|
fresh
|
||||||
}
|
}
|
||||||
token.exists { tok =>
|
token.flatMap { tok =>
|
||||||
if createNative(serverUrl, tok, form) then true
|
createNative(serverUrl, tok, form).orElse {
|
||||||
else
|
|
||||||
val refreshed = registerDirector(serverUrl, directorName)
|
val refreshed = registerDirector(serverUrl, directorName)
|
||||||
directorToken = refreshed
|
directorToken = refreshed
|
||||||
refreshed.exists(createNative(serverUrl, _, form))
|
refreshed.flatMap(createNative(serverUrl, _, form))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private def registerDirector(serverUrl: String, name: String): Option[String] =
|
private def registerDirector(serverUrl: String, name: String): Option[String] =
|
||||||
@@ -76,7 +76,7 @@ class ExternalTournamentClient:
|
|||||||
client.close()
|
client.close()
|
||||||
}.getOrElse(None)
|
}.getOrElse(None)
|
||||||
|
|
||||||
private def createNative(serverUrl: String, token: String, form: String): Boolean =
|
private def createNative(serverUrl: String, token: String, form: String): Option[String] =
|
||||||
Try {
|
Try {
|
||||||
val client = buildClient()
|
val client = buildClient()
|
||||||
val response = client
|
val response = client
|
||||||
@@ -84,11 +84,14 @@ class ExternalTournamentClient:
|
|||||||
.request(MediaType.APPLICATION_JSON)
|
.request(MediaType.APPLICATION_JSON)
|
||||||
.header("Authorization", s"Bearer $token")
|
.header("Authorization", s"Bearer $token")
|
||||||
.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED))
|
.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
|
finally
|
||||||
response.close()
|
response.close()
|
||||||
client.close()
|
client.close()
|
||||||
}.getOrElse(false)
|
}.getOrElse(None)
|
||||||
|
|
||||||
def fetchList(serverUrl: String): Option[JsonNode] =
|
def fetchList(serverUrl: String): Option[JsonNode] =
|
||||||
Try {
|
Try {
|
||||||
@@ -141,8 +144,8 @@ class ExternalTournamentClient:
|
|||||||
|
|
||||||
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
|
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
|
||||||
Try {
|
Try {
|
||||||
val client = buildClient()
|
val client = buildClient()
|
||||||
val body = objectMapper.writeValueAsString(req)
|
val body = objectMapper.writeValueAsString(req)
|
||||||
val response = client
|
val response = client
|
||||||
.target(s"$serverUrl/api/tournament/replicate")
|
.target(s"$serverUrl/api/tournament/replicate")
|
||||||
.request(MediaType.APPLICATION_JSON)
|
.request(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
+8
@@ -82,6 +82,14 @@ class TournamentService:
|
|||||||
def get(id: String): Option[Tournament] =
|
def get(id: String): Option[Tournament] =
|
||||||
tournamentRepository.findOptById(id)
|
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]) =
|
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
|
||||||
(
|
(
|
||||||
tournamentRepository.findByStatus("created"),
|
tournamentRepository.findByStatus("created"),
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ nowchess:
|
|||||||
secret: test-secret
|
secret: test-secret
|
||||||
auth:
|
auth:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
tournament:
|
||||||
|
self-url: ""
|
||||||
|
external-servers: ""
|
||||||
|
native-server-url: "http://localhost:1"
|
||||||
|
|||||||
Reference in New Issue
Block a user