Compare commits
13 Commits
analytics-0.5.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fd23c6e514 | |||
| 386ddc5c19 | |||
| a63d195cb3 | |||
| 28cbc2e184 | |||
| 1be9949c0b | |||
| 6d06edda69 | |||
| 845dc9c293 | |||
| 5b000a6e5f | |||
| 97015cb95e | |||
| a268a9acb7 | |||
| 71cb2cc56c | |||
| f43d1930d8 | |||
| da0e6d1ee2 |
@@ -610,3 +610,45 @@
|
|||||||
|
|
||||||
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
|
||||||
|
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **api:** define shared EventEnvelope and EventType for Redis EventBus ([#61](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/61)) ([595c172](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/595c172900da99de367c274488c3ccbeaef55882))
|
||||||
|
* **bot-platform:** migrate BotRegistry to Redis Streams consumer group ([#63](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/63)) ([0ad2e10](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0ad2e10999213df6dd00f0c31a088c28a4dc0083))
|
||||||
|
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* 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))
|
||||||
|
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||||
|
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
|
||||||
|
* **ws:** migrate challenge notifications to Redis Streams ([#66](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/66)) ([55f102c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/55f102cbaa684be94a158b16aaa42a50b36afaf3))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
|
||||||
|
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
|
||||||
|
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||||
|
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
|
||||||
|
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -193,6 +193,14 @@ class AccountResource:
|
|||||||
val bots = accountService.getOfficialBotAccounts()
|
val bots = accountService.getOfficialBotAccounts()
|
||||||
Response.ok(bots.map(toOfficialBotDto)).build()
|
Response.ok(bots.map(toOfficialBotDto)).build()
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/official-bots/{name}/token")
|
||||||
|
@InternalOnly
|
||||||
|
def getOfficialBotToken(@PathParam("name") name: String): Response =
|
||||||
|
accountService.getOfficialBotTokenByName(name) match
|
||||||
|
case None => Response.status(Response.Status.NOT_FOUND).build()
|
||||||
|
case Some(token) => Response.ok(RotatedTokenDto(token)).build()
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/official-bots")
|
@Path("/official-bots")
|
||||||
@RolesAllowed(Array("Admin"))
|
@RolesAllowed(Array("Admin"))
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ class AccountService:
|
|||||||
def getOfficialBotAccounts(): List[OfficialBotAccount] =
|
def getOfficialBotAccounts(): List[OfficialBotAccount] =
|
||||||
officialBotAccountRepository.findAll()
|
officialBotAccountRepository.findAll()
|
||||||
|
|
||||||
|
def getOfficialBotTokenByName(name: String): Option[String] =
|
||||||
|
officialBotAccountRepository.findByName(name).map(_.token)
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
|
def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
|
||||||
officialBotAccountRepository.findById(botId) match
|
officialBotAccountRepository.findById(botId) match
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=25
|
MINOR=26
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -50,3 +50,34 @@
|
|||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
||||||
|
## (2026-06-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **analytics:** add 7 new Spark analytics jobs and extend GameSource ([8e17c14](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8e17c14dff740cd115011dfbf17de35083b8fe46))
|
||||||
|
* **analytics:** add Dockerfile, CI workflow, and stable jar name for K8s deployment ([95215b6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/95215b6a420fd526df1aa395f9b087556c8ad03b))
|
||||||
|
* **analytics:** add PostgreSQL JDBC write-back to all four batch jobs ([0e0ea4c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0e0ea4c9893c6efed52e633e55d05ab3ed004502))
|
||||||
|
* **analytics:** add Spark batch analytics module ([259b3bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/259b3bbb24c0f23326269b93f4b3c84012f727cd))
|
||||||
|
* **analytics:** add Structured Streaming, MLlib clustering, GraphX jobs ([e1d80b9](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e1d80b9331666feea191b1fd08aa762f3581c918))
|
||||||
|
* **analytics:** always write results to PostgreSQL regardless of input source ([da0e6d1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/da0e6d1ee2d391ecb6291396f82471eb51b1b25e))
|
||||||
|
* **official-bots:** park expert bot on tournament server at startup ([#76](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/76)) ([751a58b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/751a58b6061f7434115e229a7661894c76768bc2))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
||||||
|
## (2026-06-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **analytics:** add 7 new Spark analytics jobs and extend GameSource ([8e17c14](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8e17c14dff740cd115011dfbf17de35083b8fe46))
|
||||||
|
* **analytics:** add Dockerfile, CI workflow, and stable jar name for K8s deployment ([95215b6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/95215b6a420fd526df1aa395f9b087556c8ad03b))
|
||||||
|
* **analytics:** add PostgreSQL JDBC write-back to all four batch jobs ([0e0ea4c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0e0ea4c9893c6efed52e633e55d05ab3ed004502))
|
||||||
|
* **analytics:** add Spark batch analytics module ([259b3bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/259b3bbb24c0f23326269b93f4b3c84012f727cd))
|
||||||
|
* **analytics:** add Structured Streaming, MLlib clustering, GraphX jobs ([e1d80b9](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e1d80b9331666feea191b1fd08aa762f3581c918))
|
||||||
|
* **analytics:** always write results to PostgreSQL regardless of input source ([da0e6d1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/da0e6d1ee2d391ecb6291396f82471eb51b1b25e))
|
||||||
|
* **official-bots:** park expert bot on tournament server at startup ([#76](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/76)) ([751a58b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/751a58b6061f7434115e229a7661894c76768bc2))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
||||||
|
* **analytics:** write decompressed PGN to shared PVC path for executor access ([a268a9a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a268a9acb7ba190c76e996ccf3ea3bd00e5cec92))
|
||||||
|
|||||||
@@ -60,3 +60,13 @@ object ColorAdvantageJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/color_advantage")
|
.csv(s"$outputDir/color_advantage")
|
||||||
|
|
||||||
|
stats.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_color_advantage")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ object DailyActivityJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/hourly_activity")
|
.csv(s"$outputDir/hourly_activity")
|
||||||
|
|
||||||
|
hourly.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_hourly_activity")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|
||||||
val dayName = F
|
val dayName = F
|
||||||
.when(F.col("dow") === 1, "Sunday")
|
.when(F.col("dow") === 1, "Sunday")
|
||||||
.when(F.col("dow") === 2, "Monday")
|
.when(F.col("dow") === 2, "Monday")
|
||||||
@@ -77,3 +87,13 @@ object DailyActivityJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/weekly_activity")
|
.csv(s"$outputDir/weekly_activity")
|
||||||
|
|
||||||
|
weekly.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_weekly_activity")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -46,3 +46,13 @@ object EloDistributionJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/elo_distribution")
|
.csv(s"$outputDir/elo_distribution")
|
||||||
|
|
||||||
|
distribution.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_elo_distribution")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ object GameLengthJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/game_length_distribution")
|
.csv(s"$outputDir/game_length_distribution")
|
||||||
|
|
||||||
|
distribution.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_game_length_distribution")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|
||||||
val byResult = games
|
val byResult = games
|
||||||
.groupBy("result")
|
.groupBy("result")
|
||||||
.agg(
|
.agg(
|
||||||
@@ -89,3 +99,13 @@ object GameLengthJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/game_length_by_result")
|
.csv(s"$outputDir/game_length_by_result")
|
||||||
|
|
||||||
|
byResult.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_game_length_by_result")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -145,31 +145,42 @@ object GameSource:
|
|||||||
)
|
)
|
||||||
.filter((F.col("white_id") =!= "").and(F.col("black_id") =!= ""))
|
.filter((F.col("white_id") =!= "").and(F.col("black_id") =!= ""))
|
||||||
|
|
||||||
/** Turns an http(s)/ftp URL into a cluster-local path by fetching it once with SparkContext.addFile, which
|
/** Turns an http(s)/ftp URL into a path readable by all executors.
|
||||||
* distributes the file to every executor. `.zst` is decompressed in-process and the plain `.pgn` is redistributed.
|
*
|
||||||
|
* Downloads the file once on the driver, decompresses `.zst` if needed, then writes the result to
|
||||||
|
* `NOWCHESS_PGN_CACHE_DIR` (default `/tmp`). That directory must be on a filesystem shared between the driver pod
|
||||||
|
* and all executor pods — in the k8s deployment this is the `spark-analytics-output` PVC mounted at
|
||||||
|
* `/spark-output`, so set `NOWCHESS_PGN_CACHE_DIR=/spark-output/.pgn-cache`.
|
||||||
|
*
|
||||||
|
* Skips download if the destination file already exists (cache-friendly for repeated runs).
|
||||||
* Non-URL paths are returned unchanged.
|
* Non-URL paths are returned unchanged.
|
||||||
*/
|
*/
|
||||||
private def resolvePath(spark: SparkSession, path: String): String =
|
private def resolvePath(spark: SparkSession, path: String): String =
|
||||||
if !path.matches("^(https?|ftp)://.*") then path
|
if !path.matches("^(https?|ftp)://.*") then path
|
||||||
else
|
else
|
||||||
spark.sparkContext.addFile(path)
|
val cacheDir = sys.env.getOrElse("NOWCHESS_PGN_CACHE_DIR", "/tmp")
|
||||||
val local = SparkFiles.get(baseName(path))
|
val destName = baseName(path).stripSuffix(".zst")
|
||||||
if !local.endsWith(".zst") then "file://" + local
|
val destPath = s"$cacheDir/$destName"
|
||||||
else distribute(spark, decompressZstd(local))
|
if !java.io.File(destPath).exists() then
|
||||||
|
spark.sparkContext.addFile(path)
|
||||||
|
val downloaded = SparkFiles.get(baseName(path))
|
||||||
|
if downloaded.endsWith(".zst") then decompressZstd(downloaded, destPath)
|
||||||
|
else
|
||||||
|
java.io.File(destPath).getParentFile.mkdirs()
|
||||||
|
java.nio.file.Files.copy(
|
||||||
|
java.nio.file.Paths.get(downloaded),
|
||||||
|
java.io.File(destPath).toPath,
|
||||||
|
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
"file://" + destPath
|
||||||
|
|
||||||
private def baseName(path: String): String = path.substring(path.lastIndexOf('/') + 1)
|
private def baseName(path: String): String = path.substring(path.lastIndexOf('/') + 1)
|
||||||
|
|
||||||
private def distribute(spark: SparkSession, localPath: String): String =
|
/** Decompresses a `.zst` file to `destPath` using zstd-jni (bundled with Spark at runtime). */
|
||||||
spark.sparkContext.addFile("file://" + localPath)
|
private def decompressZstd(srcPath: String, destPath: String): Unit =
|
||||||
"file://" + SparkFiles.get(baseName(localPath))
|
java.io.File(destPath).getParentFile.mkdirs()
|
||||||
|
|
||||||
/** Decompresses a `.zst` file to a temp `.pgn` using zstd-jni (bundled with Spark at runtime). */
|
|
||||||
private def decompressZstd(srcPath: String): String =
|
|
||||||
val out = java.io.File.createTempFile("lichess-", ".pgn")
|
|
||||||
out.deleteOnExit()
|
|
||||||
val in = com.github.luben.zstd.ZstdInputStream(
|
val in = com.github.luben.zstd.ZstdInputStream(
|
||||||
java.io.BufferedInputStream(java.io.FileInputStream(srcPath)),
|
java.io.BufferedInputStream(java.io.FileInputStream(srcPath)),
|
||||||
)
|
)
|
||||||
try java.nio.file.Files.copy(in, out.toPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
try java.nio.file.Files.copy(in, java.io.File(destPath).toPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
||||||
finally in.close()
|
finally in.close()
|
||||||
out.getAbsolutePath
|
|
||||||
|
|||||||
@@ -72,16 +72,15 @@ object OpeningBookJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/opening_book_top1000")
|
.csv(s"$outputDir/opening_book_top1000")
|
||||||
|
|
||||||
if !GameSource.isPgnMode then
|
top1000.write
|
||||||
top1000.write
|
.mode("overwrite")
|
||||||
.mode("overwrite")
|
.format("jdbc")
|
||||||
.format("jdbc")
|
.option("url", jdbcUrl)
|
||||||
.option("url", jdbcUrl)
|
.option("dbtable", "analytics_opening_stats")
|
||||||
.option("dbtable", "analytics_opening_stats")
|
.option("user", dbUser)
|
||||||
.option("user", dbUser)
|
.option("password", dbPass)
|
||||||
.option("password", dbPass)
|
.option("driver", "org.postgresql.Driver")
|
||||||
.option("driver", "org.postgresql.Driver")
|
.save()
|
||||||
.save()
|
|
||||||
|
|
||||||
/** Extracts the first `maxPlies` moves from a PGN column as a space-separated string.
|
/** Extracts the first `maxPlies` moves from a PGN column as a space-separated string.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -119,26 +119,25 @@ object PlayerClusteringJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/cluster_archetypes")
|
.csv(s"$outputDir/cluster_archetypes")
|
||||||
|
|
||||||
if !GameSource.isPgnMode then
|
clustersDf.write
|
||||||
clustersDf.write
|
.mode("overwrite")
|
||||||
.mode("overwrite")
|
.format("jdbc")
|
||||||
.format("jdbc")
|
.option("url", jdbcUrl)
|
||||||
.option("url", jdbcUrl)
|
.option("dbtable", "analytics_player_clusters")
|
||||||
.option("dbtable", "analytics_player_clusters")
|
.option("user", dbUser)
|
||||||
.option("user", dbUser)
|
.option("password", dbPass)
|
||||||
.option("password", dbPass)
|
.option("driver", "org.postgresql.Driver")
|
||||||
.option("driver", "org.postgresql.Driver")
|
.save()
|
||||||
.save()
|
|
||||||
|
|
||||||
archetypes.write
|
archetypes.write
|
||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.format("jdbc")
|
.format("jdbc")
|
||||||
.option("url", jdbcUrl)
|
.option("url", jdbcUrl)
|
||||||
.option("dbtable", "analytics_cluster_archetypes")
|
.option("dbtable", "analytics_cluster_archetypes")
|
||||||
.option("user", dbUser)
|
.option("user", dbUser)
|
||||||
.option("password", dbPass)
|
.option("password", dbPass)
|
||||||
.option("driver", "org.postgresql.Driver")
|
.option("driver", "org.postgresql.Driver")
|
||||||
.save()
|
.save()
|
||||||
|
|
||||||
private def buildPlayerStats(games: org.apache.spark.sql.DataFrame): org.apache.spark.sql.DataFrame =
|
private def buildPlayerStats(games: org.apache.spark.sql.DataFrame): org.apache.spark.sql.DataFrame =
|
||||||
val asWhite = games.select(
|
val asWhite = games.select(
|
||||||
|
|||||||
@@ -109,16 +109,15 @@ object PlayerGraphJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.parquet(s"$outputDir/player_graph")
|
.parquet(s"$outputDir/player_graph")
|
||||||
|
|
||||||
if !GameSource.isPgnMode then
|
result.write
|
||||||
result.write
|
.mode("overwrite")
|
||||||
.mode("overwrite")
|
.format("jdbc")
|
||||||
.format("jdbc")
|
.option("url", jdbcUrl)
|
||||||
.option("url", jdbcUrl)
|
.option("dbtable", "analytics_player_graph")
|
||||||
.option("dbtable", "analytics_player_graph")
|
.option("user", dbUser)
|
||||||
.option("user", dbUser)
|
.option("password", dbPass)
|
||||||
.option("password", dbPass)
|
.option("driver", "org.postgresql.Driver")
|
||||||
.option("driver", "org.postgresql.Driver")
|
.save()
|
||||||
.save()
|
|
||||||
|
|
||||||
// How many players belong to each connected component?
|
// How many players belong to each connected component?
|
||||||
// A large dominant component + many singletons is the expected shape.
|
// A large dominant component + many singletons is the expected shape.
|
||||||
@@ -135,6 +134,16 @@ object PlayerGraphJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/component_sizes")
|
.csv(s"$outputDir/component_sizes")
|
||||||
|
|
||||||
|
componentSizes.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_component_sizes")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|
||||||
// Build a two-column DataFrame (vertex_id: Long, valueCol: valueType) from an RDD.
|
// Build a two-column DataFrame (vertex_id: Long, valueCol: valueType) from an RDD.
|
||||||
// Used to bridge GraphX RDD results into the DataFrame API without implicits.
|
// Used to bridge GraphX RDD results into the DataFrame API without implicits.
|
||||||
private def rddToFrame[T](
|
private def rddToFrame[T](
|
||||||
|
|||||||
@@ -82,13 +82,12 @@ object PlayerStatsJob:
|
|||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/player_stats_csv")
|
.csv(s"$outputDir/player_stats_csv")
|
||||||
|
|
||||||
if !GameSource.isPgnMode then
|
stats.write
|
||||||
stats.write
|
.mode("overwrite")
|
||||||
.mode("overwrite")
|
.format("jdbc")
|
||||||
.format("jdbc")
|
.option("url", jdbcUrl)
|
||||||
.option("url", jdbcUrl)
|
.option("dbtable", "analytics_player_stats")
|
||||||
.option("dbtable", "analytics_player_stats")
|
.option("user", dbUser)
|
||||||
.option("user", dbUser)
|
.option("password", dbPass)
|
||||||
.option("password", dbPass)
|
.option("driver", "org.postgresql.Driver")
|
||||||
.option("driver", "org.postgresql.Driver")
|
.save()
|
||||||
.save()
|
|
||||||
|
|||||||
@@ -63,3 +63,13 @@ object RatingMismatchJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/rating_mismatch")
|
.csv(s"$outputDir/rating_mismatch")
|
||||||
|
|
||||||
|
stats.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_rating_mismatch")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -42,3 +42,13 @@ object TerminationStatsJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/termination_stats")
|
.csv(s"$outputDir/termination_stats")
|
||||||
|
|
||||||
|
stats.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_termination_stats")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -56,3 +56,13 @@ object TimeControlJob:
|
|||||||
.mode("overwrite")
|
.mode("overwrite")
|
||||||
.option("header", "true")
|
.option("header", "true")
|
||||||
.csv(s"$outputDir/time_control_stats")
|
.csv(s"$outputDir/time_control_stats")
|
||||||
|
|
||||||
|
stats.write
|
||||||
|
.mode("overwrite")
|
||||||
|
.format("jdbc")
|
||||||
|
.option("url", jdbcUrl)
|
||||||
|
.option("dbtable", "analytics_time_control_stats")
|
||||||
|
.option("user", dbUser)
|
||||||
|
.option("password", dbPass)
|
||||||
|
.option("driver", "org.postgresql.Driver")
|
||||||
|
.save()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=5
|
MINOR=7
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -370,3 +370,103 @@
|
|||||||
### Reverts
|
### Reverts
|
||||||
|
|
||||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **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))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* 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))
|
||||||
|
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||||
|
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||||
|
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||||
|
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **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))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* 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))
|
||||||
|
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||||
|
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||||
|
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||||
|
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||||
|
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||||
|
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||||
|
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
## (2026-06-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||||
|
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||||
|
* **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))
|
||||||
|
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||||
|
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||||
|
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||||
|
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||||
|
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||||
|
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||||
|
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||||
|
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||||
|
* 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))
|
||||||
|
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||||
|
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||||
|
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||||
|
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||||
|
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||||
|
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||||
|
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||||
|
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||||
|
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||||
|
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||||
|
|
||||||
|
### Reverts
|
||||||
|
|
||||||
|
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ nowchess:
|
|||||||
prefix: nowchess
|
prefix: nowchess
|
||||||
internal:
|
internal:
|
||||||
secret: 123abc
|
secret: 123abc
|
||||||
|
tournament:
|
||||||
|
service-url: http://localhost:8088
|
||||||
|
|
||||||
"%deployed":
|
"%deployed":
|
||||||
quarkus:
|
quarkus:
|
||||||
@@ -49,3 +51,5 @@ nowchess:
|
|||||||
prefix: ${REDIS_PREFIX:nowchess}
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
internal:
|
internal:
|
||||||
secret: ${INTERNAL_SECRET}
|
secret: ${INTERNAL_SECRET}
|
||||||
|
tournament:
|
||||||
|
service-url: ${TOURNAMENT_SERVICE_URL:http://localhost:8088}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, R
|
|||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
case class SyncOfficialBotsRequest(bots: List[String])
|
case class SyncOfficialBotsRequest(bots: List[String])
|
||||||
|
case class BotTokenResponse(token: String)
|
||||||
|
|
||||||
@Path("/api/account/official-bots")
|
@Path("/api/account/official-bots")
|
||||||
@RegisterRestClient(configKey = "account-service")
|
@RegisterRestClient(configKey = "account-service")
|
||||||
@@ -18,3 +19,8 @@ trait AccountServiceClient:
|
|||||||
@Path("/sync")
|
@Path("/sync")
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
def syncBots(req: SyncOfficialBotsRequest): Unit
|
def syncBots(req: SyncOfficialBotsRequest): Unit
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{name}/token")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
def getBotToken(@PathParam("name") name: String): BotTokenResponse
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package de.nowchess.bot.resource
|
|||||||
|
|
||||||
case class JoinTournamentRequest(
|
case class JoinTournamentRequest(
|
||||||
tournamentId: String,
|
tournamentId: String,
|
||||||
botToken: String,
|
botToken: Option[String],
|
||||||
difficulty: String,
|
difficulty: String,
|
||||||
serverUrl: Option[String],
|
serverUrl: Option[String],
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-5
@@ -25,20 +25,18 @@ class TournamentJoinResource:
|
|||||||
@POST
|
@POST
|
||||||
@Path("/join-tournament")
|
@Path("/join-tournament")
|
||||||
def joinTournament(req: JoinTournamentRequest): Response =
|
def joinTournament(req: JoinTournamentRequest): Response =
|
||||||
val serverUrl = req.serverUrl.filter(_.nonEmpty).getOrElse(player.defaultServerUrl)
|
|
||||||
val difficulty = if req.difficulty.nonEmpty then req.difficulty else "medium"
|
val difficulty = if req.difficulty.nonEmpty then req.difficulty else "medium"
|
||||||
log.infof(
|
log.infof(
|
||||||
"Official bot join requested — tournament=%s difficulty=%s server=%s",
|
"Official bot join requested — tournament=%s difficulty=%s",
|
||||||
req.tournamentId,
|
req.tournamentId,
|
||||||
difficulty,
|
difficulty,
|
||||||
serverUrl,
|
|
||||||
)
|
)
|
||||||
player.joinTournament(req.tournamentId, req.botToken, difficulty, serverUrl) match
|
player.joinTournament(req.tournamentId, req.botToken, difficulty) match
|
||||||
case Right(botId) =>
|
case Right(botId) =>
|
||||||
val resp = JoinTournamentResponse(botId, difficulty, "joining")
|
val resp = JoinTournamentResponse(botId, difficulty, "joining")
|
||||||
Response.ok(resp).build()
|
Response.ok(resp).build()
|
||||||
case Left(err) =>
|
case Left(err) =>
|
||||||
Response
|
Response
|
||||||
.status(Response.Status.BAD_GATEWAY)
|
.status(Response.Status.BAD_REQUEST)
|
||||||
.entity(s"""{"error":"$err"}""")
|
.entity(s"""{"error":"$err"}""")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+8
-4
@@ -16,13 +16,17 @@ object TournamentBotConfig:
|
|||||||
private val mapper = new ObjectMapper()
|
private val mapper = new ObjectMapper()
|
||||||
|
|
||||||
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
|
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
|
||||||
|
fromEnvWithToken(env, None)
|
||||||
|
|
||||||
|
def fromEnvWithToken(env: Map[String, String], resolvedToken: Option[String]): Option[TournamentBotConfig] =
|
||||||
|
val token = env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty).orElse(resolvedToken)
|
||||||
for
|
for
|
||||||
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
|
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
|
||||||
token <- env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
|
tok <- token
|
||||||
botId <- jwtSubject(token)
|
botId <- jwtSubject(tok)
|
||||||
serverUrl = env.getOrElse("TOURNAMENT_SERVER_URL", "http://141.37.123.132:8086")
|
serverUrl = env.getOrElse("TOURNAMENT_SERVICE_URL", "http://localhost:8086")
|
||||||
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
|
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
|
||||||
yield TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty)
|
yield TournamentBotConfig(serverUrl, tournamentId, tok, botId, difficulty)
|
||||||
|
|
||||||
def jwtSubject(token: String): Option[String] =
|
def jwtSubject(token: String): Option[String] =
|
||||||
Try {
|
Try {
|
||||||
|
|||||||
+89
-42
@@ -3,13 +3,17 @@ package de.nowchess.bot.service
|
|||||||
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.bot.{Bot, BotController}
|
import de.nowchess.bot.{Bot, BotController}
|
||||||
|
import de.nowchess.bot.client.AccountServiceClient
|
||||||
|
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.Startup
|
import io.quarkus.runtime.Startup
|
||||||
import jakarta.annotation.{PostConstruct, PreDestroy}
|
import jakarta.annotation.{PostConstruct, PreDestroy}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
@@ -26,74 +30,117 @@ class TournamentBotGamePlayer:
|
|||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var botController: BotController = uninitialized
|
@Inject var botController: BotController = uninitialized
|
||||||
|
@Inject var redis: RedisDataSource = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val client: Client = ClientBuilder.newClient()
|
private val client: Client = ClientBuilder.newClient()
|
||||||
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
||||||
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
||||||
|
|
||||||
private val config = TournamentBotConfig.fromEnv(System.getenv().asScala.toMap)
|
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@volatile private var running = true
|
@volatile private var running = true
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
val defaultServerUrl: String =
|
val tournamentServiceUrl: String =
|
||||||
System.getenv().asScala.getOrElse("TOURNAMENT_SERVER_URL", "http://141.37.123.132:8086")
|
System.getenv().asScala.getOrElse("TOURNAMENT_SERVICE_URL", "http://localhost:8086")
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
def initialize(): Unit =
|
def initialize(): Unit =
|
||||||
parkOnStartup()
|
val env = System.getenv().asScala.toMap
|
||||||
config match
|
val difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
|
||||||
|
val token = resolveToken(difficulty)
|
||||||
|
parkOnStartup(token)
|
||||||
|
TournamentBotConfig.fromEnvWithToken(env, token) match
|
||||||
case None =>
|
case None =>
|
||||||
log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable")
|
log.info("Tournament bot disabled — set TOURNAMENT_ID to enable")
|
||||||
case Some(cfg) =>
|
case Some(cfg) =>
|
||||||
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
||||||
startAsync(cfg)
|
startAsync(cfg)
|
||||||
|
|
||||||
private def parkOnStartup(): Unit =
|
private def resolveToken(difficulty: String): Option[String] =
|
||||||
park(defaultServerUrl, "expert") match
|
val name = botName(difficulty)
|
||||||
case Some(id) => log.infof("Parked expert bot on %s as id %s", defaultServerUrl, id)
|
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name"
|
||||||
case None => log.warnf("Failed to park expert bot on %s", defaultServerUrl)
|
Try(accountServiceClient.getBotToken(name).token)
|
||||||
|
.toOption
|
||||||
private def park(serverUrl: String, difficulty: String): Option[String] =
|
.filter(_.nonEmpty)
|
||||||
System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty).flatMap { token =>
|
.map { token =>
|
||||||
Try {
|
redis.value(classOf[String]).set(redisKey, token)
|
||||||
val body = s"""{"name":"${botName(difficulty)}"}"""
|
log.infof("Fetched fresh bot token for %s from account service", name)
|
||||||
val response = client
|
token
|
||||||
.target(serverUrl)
|
}
|
||||||
.path("api")
|
.orElse {
|
||||||
.path("bots")
|
Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty).map { token =>
|
||||||
.request(MediaType.APPLICATION_JSON)
|
log.infof("Using cached bot token for %s from Redis", name)
|
||||||
.header("Authorization", s"Bearer $token")
|
token
|
||||||
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
|
||||||
if response.getStatus == 201 || response.getStatus == 200 then
|
|
||||||
val id = objectMapper.readTree(response.readEntity(classOf[String])).path("id").asText()
|
|
||||||
response.close()
|
|
||||||
Option(id).filter(_.nonEmpty)
|
|
||||||
else {
|
|
||||||
log.warnf("Parking bot %s returned status %d", botName(difficulty), response.getStatus); response.close();
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}.getOrElse(None)
|
}
|
||||||
}
|
.orElse {
|
||||||
|
System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty).map { token =>
|
||||||
|
log.infof("Using TOURNAMENT_BOT_TOKEN env var for %s", name)
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def parkOnStartup(token: Option[String]): Unit =
|
||||||
|
token match
|
||||||
|
case None => log.warn("No bot token resolved — skipping park")
|
||||||
|
case Some(tok) =>
|
||||||
|
val localAccountUrl = System.getenv().asScala.getOrElse("ACCOUNT_SERVICE_URL", "http://localhost:8083")
|
||||||
|
BotController.listBots.foreach(diff => parkOn(localAccountUrl, diff, tok))
|
||||||
|
fetchRemoteServers().foreach { serverUrl =>
|
||||||
|
BotController.listBots.foreach(diff => parkOn(serverUrl, diff, tok))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def fetchRemoteServers(): List[String] =
|
||||||
|
Try {
|
||||||
|
val response = client.target(tournamentServiceUrl)
|
||||||
|
.path("api").path("tournament").path("servers")
|
||||||
|
.request(MediaType.APPLICATION_JSON).get()
|
||||||
|
if response.getStatus == 200 then
|
||||||
|
val node = objectMapper.readTree(response.readEntity(classOf[String]))
|
||||||
|
response.close()
|
||||||
|
node.path("servers").elements().asScala.toList.map(_.path("url").asText()).filter(_.nonEmpty)
|
||||||
|
else { response.close(); Nil }
|
||||||
|
}.getOrElse(Nil)
|
||||||
|
|
||||||
|
private def parkOn(serverUrl: String, difficulty: String, token: String): Unit =
|
||||||
|
Try {
|
||||||
|
val body = s"""{"name":"${botName(difficulty)}"}"""
|
||||||
|
val response = client.target(serverUrl).path("api").path("account").path("bots")
|
||||||
|
.request(MediaType.APPLICATION_JSON)
|
||||||
|
.header("Authorization", s"Bearer $token")
|
||||||
|
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
||||||
|
if response.getStatus == 201 || response.getStatus == 200 then
|
||||||
|
val id = objectMapper.readTree(response.readEntity(classOf[String])).path("id").asText()
|
||||||
|
log.infof("Parked bot %s on %s as id %s", botName(difficulty), serverUrl, id)
|
||||||
|
else log.warnf("Park %s on %s returned status %d", botName(difficulty), serverUrl, response.getStatus)
|
||||||
|
response.close()
|
||||||
|
}.failed.foreach(ex => log.warnf(ex, "Failed to park %s on %s", botName(difficulty), serverUrl))
|
||||||
|
|
||||||
private def botName(difficulty: String): String = s"NowChess ${difficulty.capitalize}"
|
private def botName(difficulty: String): String = s"NowChess ${difficulty.capitalize}"
|
||||||
|
|
||||||
def joinTournament(
|
def joinTournament(
|
||||||
tournamentId: String,
|
tournamentId: String,
|
||||||
botToken: String,
|
botToken: Option[String],
|
||||||
difficulty: String,
|
difficulty: String,
|
||||||
serverUrl: String,
|
|
||||||
): Either[String, String] =
|
): Either[String, String] =
|
||||||
TournamentBotConfig.jwtSubject(botToken) match
|
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
|
||||||
case None => Left("Invalid bot token — could not extract subject")
|
val resolvedToken = botToken.filter(_.nonEmpty)
|
||||||
case Some(botId) =>
|
.orElse(System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty))
|
||||||
val cfg = TournamentBotConfig(serverUrl, tournamentId, botToken, botId, difficulty)
|
.orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty))
|
||||||
if join(cfg) then
|
resolvedToken match
|
||||||
startAsync(cfg)
|
case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured")
|
||||||
Right(botId)
|
case Some(token) =>
|
||||||
else Left("Failed to join tournament")
|
TournamentBotConfig.jwtSubject(token) match
|
||||||
|
case None => Left("Invalid bot token — could not extract subject")
|
||||||
|
case Some(botId) =>
|
||||||
|
val cfg = TournamentBotConfig(tournamentServiceUrl, tournamentId, token, botId, difficulty)
|
||||||
|
if join(cfg) then
|
||||||
|
startAsync(cfg)
|
||||||
|
Right(botId)
|
||||||
|
else Left("Failed to join tournament")
|
||||||
|
|
||||||
private def startAsync(cfg: TournamentBotConfig): Unit =
|
private def startAsync(cfg: TournamentBotConfig): Unit =
|
||||||
val thread = new Thread(() => streamLoop(cfg), s"TournamentBot-${cfg.tournamentId}")
|
val thread = new Thread(() => streamLoop(cfg), s"TournamentBot-${cfg.tournamentId}")
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=21
|
MINOR=24
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -37,3 +37,21 @@
|
|||||||
|
|
||||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||||
|
## (2026-06-21)
|
||||||
|
|
||||||
|
### 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:** 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:** 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))
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ nowchess:
|
|||||||
prefix: ${REDIS_PREFIX:nowchess}
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
internal:
|
internal:
|
||||||
secret: ${INTERNAL_SECRET:123abc}
|
secret: ${INTERNAL_SECRET:123abc}
|
||||||
|
tournament:
|
||||||
|
self-url: ""
|
||||||
|
external-servers: ""
|
||||||
|
|
||||||
mp:
|
mp:
|
||||||
jwt:
|
jwt:
|
||||||
@@ -46,6 +49,10 @@ mp:
|
|||||||
hibernate-orm:
|
hibernate-orm:
|
||||||
schema-management:
|
schema-management:
|
||||||
strategy: update
|
strategy: update
|
||||||
|
nowchess:
|
||||||
|
tournament:
|
||||||
|
self-url: ${TOURNAMENT_SELF_URL:}
|
||||||
|
external-servers: ${TOURNAMENT_EXTERNAL_SERVERS:}
|
||||||
|
|
||||||
"%test":
|
"%test":
|
||||||
quarkus:
|
quarkus:
|
||||||
|
|||||||
+1
-1
@@ -26,13 +26,13 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
|||||||
classOf[RoundPairingsDto],
|
classOf[RoundPairingsDto],
|
||||||
classOf[ErrorDto],
|
classOf[ErrorDto],
|
||||||
classOf[OkDto],
|
classOf[OkDto],
|
||||||
|
classOf[ReplicateTournamentRequest],
|
||||||
classOf[CorePlayerInfo],
|
classOf[CorePlayerInfo],
|
||||||
classOf[CoreTimeControl],
|
classOf[CoreTimeControl],
|
||||||
classOf[CoreCreateGameRequest],
|
classOf[CoreCreateGameRequest],
|
||||||
classOf[CoreGameResponse],
|
classOf[CoreGameResponse],
|
||||||
classOf[GameWritebackEventDto],
|
classOf[GameWritebackEventDto],
|
||||||
classOf[ExternalTournamentServer],
|
classOf[ExternalTournamentServer],
|
||||||
classOf[RegisterServerRequest],
|
|
||||||
classOf[ExternalTournamentServerList],
|
classOf[ExternalTournamentServerList],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,4 +30,7 @@ class Tournament:
|
|||||||
var startsAt: Instant = uninitialized
|
var startsAt: Instant = uninitialized
|
||||||
var winnerId: String = uninitialized
|
var winnerId: String = uninitialized
|
||||||
var winnerName: String = uninitialized
|
var winnerName: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
var originServerUrl: String = null
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.nowchess.tournament.dto
|
package de.nowchess.tournament.dto
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
case class BotRef(id: String, name: String)
|
case class BotRef(id: String, name: String)
|
||||||
|
|
||||||
case class Clock(limit: Int, increment: Int)
|
case class Clock(limit: Int, increment: Int)
|
||||||
@@ -72,3 +74,15 @@ case class RoundPairingsDto(round: Int, pairings: List[PairingDto])
|
|||||||
case class ErrorDto(error: String)
|
case class ErrorDto(error: String)
|
||||||
|
|
||||||
case class OkDto(ok: Boolean = true)
|
case class OkDto(ok: Boolean = true)
|
||||||
|
|
||||||
|
case class ReplicateTournamentRequest(
|
||||||
|
id: String,
|
||||||
|
fullName: String,
|
||||||
|
nbRounds: Int,
|
||||||
|
clockLimit: Int,
|
||||||
|
clockIncrement: Int,
|
||||||
|
rated: Boolean,
|
||||||
|
createdBy: String,
|
||||||
|
startsAt: Instant,
|
||||||
|
status: String,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
package de.nowchess.tournament.dto
|
package de.nowchess.tournament.dto
|
||||||
|
|
||||||
case class ExternalTournamentServer(id: String, label: String, url: String)
|
case class ExternalTournamentServer(id: String, label: String, url: String)
|
||||||
case class RegisterServerRequest(label: String, url: String)
|
|
||||||
case class ExternalTournamentServerList(servers: List[ExternalTournamentServer])
|
case class ExternalTournamentServerList(servers: List[ExternalTournamentServer])
|
||||||
|
|||||||
+190
-60
@@ -9,13 +9,14 @@ import de.nowchess.tournament.service.{
|
|||||||
TournamentService,
|
TournamentService,
|
||||||
TournamentStreamManager,
|
TournamentStreamManager,
|
||||||
}
|
}
|
||||||
import io.smallrye.mutiny.Multi
|
|
||||||
import jakarta.annotation.security.{PermitAll, RolesAllowed}
|
import jakarta.annotation.security.{PermitAll, RolesAllowed}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.ws.rs.*
|
import jakarta.ws.rs.*
|
||||||
import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response, StreamingOutput}
|
import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response, StreamingOutput}
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
|
import java.util.Optional
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
@@ -36,6 +37,9 @@ class TournamentResource:
|
|||||||
@Inject var externalClient: ExternalTournamentClient = uninitialized
|
@Inject var externalClient: ExternalTournamentClient = uninitialized
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Context var headers: HttpHeaders = uninitialized
|
@Context var headers: HttpHeaders = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.tournament.self-url")
|
||||||
|
var selfUrl: Optional[String] = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -85,6 +89,12 @@ class TournamentResource:
|
|||||||
val userId = Option(jwt.getSubject).getOrElse("")
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
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 =>
|
||||||
|
registry.serverUrls().foreach { remoteUrl =>
|
||||||
|
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
|
||||||
|
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build()
|
Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build()
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -100,33 +110,59 @@ class TournamentResource:
|
|||||||
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
|
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
|
||||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build())
|
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build())
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/replicate")
|
||||||
|
@PermitAll
|
||||||
|
def replicate(req: ReplicateTournamentRequest): Response =
|
||||||
|
val originUrl = Option(headers.getHeaderString("X-Origin-Url")).getOrElse("")
|
||||||
|
if originUrl.isEmpty then
|
||||||
|
Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto("Missing X-Origin-Url header")).build()
|
||||||
|
else
|
||||||
|
tournamentService.get(req.id) match
|
||||||
|
case Some(_) => Response.status(Response.Status.CONFLICT).entity(ErrorDto("Tournament already exists")).build()
|
||||||
|
case None =>
|
||||||
|
tournamentService.replicate(req, originUrl)
|
||||||
|
Response.status(Response.Status.CREATED).build()
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
def terminate(@PathParam("id") id: String): Response =
|
def terminate(@PathParam("id") id: String): Response =
|
||||||
val userId = Option(jwt.getSubject).getOrElse("")
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
tournamentService.terminate(id, userId) match
|
tournamentService.get(id).flatMap(t => Option(t.originServerUrl)) match
|
||||||
case Right(_) => Response.noContent().build()
|
case Some(originUrl) =>
|
||||||
case Left(error) => errorResponse(error)
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
|
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id", auth)
|
||||||
|
Response.status(status).entity(body).build()
|
||||||
|
case None =>
|
||||||
|
tournamentService.terminate(id, userId) match
|
||||||
|
case Right(_) => Response.noContent().build()
|
||||||
|
case Left(error) => errorResponse(error)
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{id}/start")
|
@Path("/{id}/start")
|
||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
def start(@PathParam("id") id: String): Response =
|
def start(@PathParam("id") id: String): Response =
|
||||||
val userId = Option(jwt.getSubject).getOrElse("")
|
val userId = Option(jwt.getSubject).getOrElse("")
|
||||||
tournamentService.start(id, userId) match
|
tournamentService.get(id).flatMap(t => Option(t.originServerUrl)) match
|
||||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
case Some(originUrl) =>
|
||||||
case Left(error) =>
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
error match
|
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth)
|
||||||
case TournamentError.NotFound(_) =>
|
Response.status(status).entity(body).build()
|
||||||
val auth = Option(headers.getHeaderString("Authorization"))
|
case None =>
|
||||||
resolveServer(id)
|
tournamentService.start(id, userId) match
|
||||||
.map { url =>
|
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
case Left(error) =>
|
||||||
Response.status(status).entity(body).build()
|
error match
|
||||||
}
|
case TournamentError.NotFound(_) =>
|
||||||
.getOrElse(errorResponse(error))
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
case _ => errorResponse(error)
|
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")
|
||||||
@@ -136,21 +172,27 @@ class TournamentResource:
|
|||||||
if tokenType != "bot" then
|
if tokenType != "bot" then
|
||||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can join tournaments")).build()
|
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can join tournaments")).build()
|
||||||
else
|
else
|
||||||
val botId = Option(jwt.getSubject).getOrElse("")
|
tournamentService.get(id).flatMap(t => Option(t.originServerUrl)) match
|
||||||
val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId)
|
case Some(originUrl) =>
|
||||||
tournamentService.join(id, botId, botName) match
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
case Right(_) => Response.ok(OkDto()).build()
|
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/join", auth)
|
||||||
case Left(error) =>
|
Response.status(status).entity(body).build()
|
||||||
error match
|
case None =>
|
||||||
case TournamentError.NotFound(_) =>
|
val botId = Option(jwt.getSubject).getOrElse("")
|
||||||
val auth = Option(headers.getHeaderString("Authorization"))
|
val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId)
|
||||||
resolveServer(id)
|
tournamentService.join(id, botId, botName) match
|
||||||
.map { url =>
|
case Right(_) => Response.ok(OkDto()).build()
|
||||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/join", auth)
|
case Left(error) =>
|
||||||
Response.status(status).entity(body).build()
|
error match
|
||||||
}
|
case TournamentError.NotFound(_) =>
|
||||||
.getOrElse(errorResponse(error))
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
case _ => errorResponse(error)
|
resolveServer(id)
|
||||||
|
.map { url =>
|
||||||
|
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/join", auth)
|
||||||
|
Response.status(status).entity(body).build()
|
||||||
|
}
|
||||||
|
.getOrElse(errorResponse(error))
|
||||||
|
case _ => errorResponse(error)
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{id}/withdraw")
|
@Path("/{id}/withdraw")
|
||||||
@@ -160,20 +202,26 @@ class TournamentResource:
|
|||||||
if tokenType != "bot" then
|
if tokenType != "bot" then
|
||||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can withdraw")).build()
|
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can withdraw")).build()
|
||||||
else
|
else
|
||||||
val botId = Option(jwt.getSubject).getOrElse("")
|
tournamentService.get(id).flatMap(t => Option(t.originServerUrl)) match
|
||||||
tournamentService.withdraw(id, botId) match
|
case Some(originUrl) =>
|
||||||
case Right(_) => Response.ok(OkDto()).build()
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
case Left(error) =>
|
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/withdraw", auth)
|
||||||
error match
|
Response.status(status).entity(body).build()
|
||||||
case TournamentError.NotFound(_) =>
|
case None =>
|
||||||
val auth = Option(headers.getHeaderString("Authorization"))
|
val botId = Option(jwt.getSubject).getOrElse("")
|
||||||
resolveServer(id)
|
tournamentService.withdraw(id, botId) match
|
||||||
.map { url =>
|
case Right(_) => Response.ok(OkDto()).build()
|
||||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/withdraw", auth)
|
case Left(error) =>
|
||||||
Response.status(status).entity(body).build()
|
error match
|
||||||
}
|
case TournamentError.NotFound(_) =>
|
||||||
.getOrElse(errorResponse(error))
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
case _ => errorResponse(error)
|
resolveServer(id)
|
||||||
|
.map { url =>
|
||||||
|
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/withdraw", auth)
|
||||||
|
Response.status(status).entity(body).build()
|
||||||
|
}
|
||||||
|
.getOrElse(errorResponse(error))
|
||||||
|
case _ => errorResponse(error)
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}/results")
|
@Path("/{id}/results")
|
||||||
@@ -236,15 +284,81 @@ class TournamentResource:
|
|||||||
@Path("/{id}/stream")
|
@Path("/{id}/stream")
|
||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
@Produces(Array("application/x-ndjson"))
|
@Produces(Array("application/x-ndjson"))
|
||||||
def stream(@PathParam("id") id: String): Multi[String] =
|
def stream(@PathParam("id") id: String): Response =
|
||||||
tournamentService.get(id) match
|
tournamentService.get(id) match
|
||||||
case None => Multi.createFrom().failure(new NotFoundException(s"Tournament $id not found"))
|
case Some(t) if Option(t.originServerUrl).isDefined =>
|
||||||
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
|
externalClient.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
|
||||||
|
.map { inputStream =>
|
||||||
|
Response
|
||||||
|
.ok(new StreamingOutput {
|
||||||
|
def write(output: java.io.OutputStream): Unit =
|
||||||
|
val buf = new Array[Byte](4096)
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var n = inputStream.read(buf)
|
||||||
|
while n >= 0 do
|
||||||
|
output.write(buf, 0, n)
|
||||||
|
output.flush()
|
||||||
|
n = inputStream.read(buf)
|
||||||
|
// scalafix:on
|
||||||
|
})
|
||||||
|
.`type`("application/x-ndjson")
|
||||||
|
.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("")
|
||||||
Multi.createFrom().emitter[String] { emitter =>
|
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
|
||||||
streamManager.register(id, botId, emitter)
|
val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] {
|
||||||
emitter.onTermination(() => streamManager.unregister(id, botId, emitter))
|
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
|
||||||
|
queue.put(Some(item)); this
|
||||||
|
def fail(failure: Throwable): Unit = queue.put(None)
|
||||||
|
def complete(): Unit = queue.put(None)
|
||||||
|
def requested(): Long = Long.MaxValue
|
||||||
|
def isCancelled: Boolean = false
|
||||||
|
def onTermination(
|
||||||
|
onTermination: java.lang.Runnable,
|
||||||
|
): io.smallrye.mutiny.subscription.MultiEmitter[String] = this
|
||||||
}
|
}
|
||||||
|
streamManager.register(id, botId, emitter)
|
||||||
|
Response
|
||||||
|
.ok(new StreamingOutput {
|
||||||
|
def write(output: java.io.OutputStream): Unit =
|
||||||
|
try
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var cont = true
|
||||||
|
while cont do
|
||||||
|
queue.take() match
|
||||||
|
case None => cont = false
|
||||||
|
case Some(line) =>
|
||||||
|
output.write((line + "\n").getBytes("UTF-8"))
|
||||||
|
output.flush()
|
||||||
|
// scalafix:on
|
||||||
|
finally streamManager.unregister(id, botId, emitter)
|
||||||
|
})
|
||||||
|
.`type`("application/x-ndjson")
|
||||||
|
.build()
|
||||||
|
case None =>
|
||||||
|
val auth = Option(headers.getHeaderString("Authorization"))
|
||||||
|
resolveServer(id)
|
||||||
|
.flatMap(url => externalClient.proxyGetStream(url, s"api/tournament/$id/stream", auth))
|
||||||
|
.map { inputStream =>
|
||||||
|
Response
|
||||||
|
.ok(new StreamingOutput {
|
||||||
|
def write(output: java.io.OutputStream): Unit =
|
||||||
|
val buf = new Array[Byte](4096)
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var n = inputStream.read(buf)
|
||||||
|
while n >= 0 do
|
||||||
|
output.write(buf, 0, n)
|
||||||
|
output.flush()
|
||||||
|
n = inputStream.read(buf)
|
||||||
|
// scalafix:on
|
||||||
|
})
|
||||||
|
.`type`("application/x-ndjson")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build())
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}/game/{gameId}")
|
@Path("/{id}/game/{gameId}")
|
||||||
@@ -297,15 +411,31 @@ 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] =
|
||||||
registry.findServerUrl(tournamentId).orElse {
|
tournamentService.get(tournamentId)
|
||||||
registry
|
.flatMap(t => Option(t.originServerUrl))
|
||||||
.serverUrls()
|
.orElse(registry.findServerUrl(tournamentId))
|
||||||
.find(url => externalClient.fetch(url, tournamentId).isDefined)
|
.orElse {
|
||||||
.map { url =>
|
registry
|
||||||
registry.bindTournament(tournamentId, url)
|
.serverUrls()
|
||||||
url
|
.find(url => externalClient.fetch(url, tournamentId).isDefined)
|
||||||
}
|
.map { url =>
|
||||||
}
|
registry.bindTournament(tournamentId, url)
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def toReplicateRequest(t: de.nowchess.tournament.domain.Tournament): ReplicateTournamentRequest =
|
||||||
|
ReplicateTournamentRequest(
|
||||||
|
id = t.id,
|
||||||
|
fullName = t.fullName,
|
||||||
|
nbRounds = t.nbRounds,
|
||||||
|
clockLimit = t.clockLimit,
|
||||||
|
clockIncrement = t.clockIncrement,
|
||||||
|
rated = t.rated,
|
||||||
|
createdBy = t.createdBy,
|
||||||
|
startsAt = Option(t.startsAt).getOrElse(java.time.Instant.now()),
|
||||||
|
status = t.status,
|
||||||
|
)
|
||||||
|
|
||||||
private def errorResponse(error: TournamentError): Response =
|
private def errorResponse(error: TournamentError): Response =
|
||||||
val status = error match
|
val status = error match
|
||||||
|
|||||||
+1
-11
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.tournament.resource
|
package de.nowchess.tournament.resource
|
||||||
|
|
||||||
import de.nowchess.tournament.dto.{ErrorDto, ExternalTournamentServerList, RegisterServerRequest}
|
import de.nowchess.tournament.dto.ExternalTournamentServerList
|
||||||
import de.nowchess.tournament.service.TournamentServerRegistry
|
import de.nowchess.tournament.service.TournamentServerRegistry
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
@@ -23,13 +23,3 @@ class TournamentServerResource:
|
|||||||
@GET
|
@GET
|
||||||
def list(): Response =
|
def list(): Response =
|
||||||
Response.ok(ExternalTournamentServerList(registry.list())).build()
|
Response.ok(ExternalTournamentServerList(registry.list())).build()
|
||||||
|
|
||||||
@POST
|
|
||||||
def register(req: RegisterServerRequest): Response =
|
|
||||||
Response.status(201).entity(registry.register(req.label, req.url)).build()
|
|
||||||
|
|
||||||
@DELETE
|
|
||||||
@Path("/{id}")
|
|
||||||
def remove(@PathParam("id") id: String): Response =
|
|
||||||
if registry.remove(id) then Response.noContent().build()
|
|
||||||
else Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Server $id not found")).build()
|
|
||||||
|
|||||||
+16
@@ -1,6 +1,7 @@
|
|||||||
package de.nowchess.tournament.service
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
|
||||||
|
import de.nowchess.tournament.dto.ReplicateTournamentRequest
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
||||||
@@ -66,6 +67,21 @@ class ExternalTournamentClient:
|
|||||||
client.close()
|
client.close()
|
||||||
}.getOrElse((502, """{"error":"External server unreachable"}"""))
|
}.getOrElse((502, """{"error":"External server unreachable"}"""))
|
||||||
|
|
||||||
|
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
|
||||||
|
Try {
|
||||||
|
val client = buildClient()
|
||||||
|
val body = objectMapper.writeValueAsString(req)
|
||||||
|
val response = client
|
||||||
|
.target(s"$serverUrl/api/tournament/replicate")
|
||||||
|
.request(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-Origin-Url", selfUrl)
|
||||||
|
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
||||||
|
try response.getStatus / 100 == 2
|
||||||
|
finally
|
||||||
|
response.close()
|
||||||
|
client.close()
|
||||||
|
}.getOrElse(false)
|
||||||
|
|
||||||
def proxyGetStream(serverUrl: String, path: String, authHeader: Option[String]): Option[java.io.InputStream] =
|
def proxyGetStream(serverUrl: String, path: String, authHeader: Option[String]): Option[java.io.InputStream] =
|
||||||
Try {
|
Try {
|
||||||
val client = buildClient()
|
val client = buildClient()
|
||||||
|
|||||||
+13
-1
@@ -1,17 +1,29 @@
|
|||||||
package de.nowchess.tournament.service
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
import de.nowchess.tournament.dto.ExternalTournamentServer
|
import de.nowchess.tournament.dto.ExternalTournamentServer
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import java.util.UUID
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import java.util.{Optional, UUID}
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class TournamentServerRegistry:
|
class TournamentServerRegistry:
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.tournament.external-servers")
|
||||||
|
var externalServers: Optional[String] = scala.compiletime.uninitialized
|
||||||
|
|
||||||
private val servers = new ConcurrentHashMap[String, ExternalTournamentServer]()
|
private val servers = new ConcurrentHashMap[String, ExternalTournamentServer]()
|
||||||
private val tournaments = new ConcurrentHashMap[String, String]()
|
private val tournaments = new ConcurrentHashMap[String, String]()
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
def init(): Unit =
|
||||||
|
if externalServers.isPresent then
|
||||||
|
externalServers.get().split(",").map(_.trim).filter(_.nonEmpty).foreach { url =>
|
||||||
|
register(url, url)
|
||||||
|
}
|
||||||
|
|
||||||
def register(label: String, url: String): ExternalTournamentServer =
|
def register(label: String, url: String): ExternalTournamentServer =
|
||||||
val id = UUID.randomUUID().toString
|
val id = UUID.randomUUID().toString
|
||||||
val server = ExternalTournamentServer(id, label, url.stripSuffix("/"))
|
val server = ExternalTournamentServer(id, label, url.stripSuffix("/"))
|
||||||
|
|||||||
+18
@@ -9,6 +9,7 @@ import de.nowchess.tournament.dto.{
|
|||||||
Clock,
|
Clock,
|
||||||
CreateTournamentForm,
|
CreateTournamentForm,
|
||||||
PairingDto,
|
PairingDto,
|
||||||
|
ReplicateTournamentRequest,
|
||||||
ResultDto,
|
ResultDto,
|
||||||
Standing,
|
Standing,
|
||||||
TournamentDto,
|
TournamentDto,
|
||||||
@@ -61,6 +62,23 @@ class TournamentService:
|
|||||||
tournamentRepository.persist(t)
|
tournamentRepository.persist(t)
|
||||||
t
|
t
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def replicate(req: ReplicateTournamentRequest, originServerUrl: String): Tournament =
|
||||||
|
val t = new Tournament()
|
||||||
|
t.id = req.id
|
||||||
|
t.fullName = req.fullName
|
||||||
|
t.nbRounds = req.nbRounds
|
||||||
|
t.clockLimit = req.clockLimit
|
||||||
|
t.clockIncrement = req.clockIncrement
|
||||||
|
t.rated = req.rated
|
||||||
|
t.status = req.status
|
||||||
|
t.currentRound = 0
|
||||||
|
t.createdBy = req.createdBy
|
||||||
|
t.startsAt = req.startsAt
|
||||||
|
t.originServerUrl = originServerUrl
|
||||||
|
tournamentRepository.persist(t)
|
||||||
|
t
|
||||||
|
|
||||||
def get(id: String): Option[Tournament] =
|
def get(id: String): Option[Tournament] =
|
||||||
tournamentRepository.findOptById(id)
|
tournamentRepository.findOptById(id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=4
|
MINOR=5
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user