fix(tournament): sync native-server participants and route start (#78)
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:
2026-06-23 20:34:30 +02:00
parent e2b13c0c8f
commit 1f4e9c8498
5 changed files with 101 additions and 39 deletions
@@ -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
@@ -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 {
@@ -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)
@@ -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"