feat(coordinator): add Redis integration and improve configuration for game state management
Build & Test (NowChessSystems) TeamCity build was removed from queue
Build & Test (NowChessSystems) TeamCity build was removed from queue
This commit is contained in:
@@ -1,316 +0,0 @@
|
|||||||
# Coordinator Microservice Implementation Guide
|
|
||||||
|
|
||||||
## Status: Proto Compilation Blockers (Fixable)
|
|
||||||
|
|
||||||
**Completed**: Module scaffold, InstanceHeartbeatService, GameRedisSubscriberManager updates, gRPC handlers, REST API stubs.
|
|
||||||
|
|
||||||
**Blocking**: Proto file → Java stubs not resolving in Scala imports. Solution documented below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**Goal**: <300ms failover via gRPC bidirectional stream detection + sub-1s game migration.
|
|
||||||
|
|
||||||
**Core Flow**:
|
|
||||||
1. Core sends `HeartbeatFrame` every 200ms on stream to coordinator
|
|
||||||
2. Core posts `{prefix}:instance:{id}:games` Redis Set (SADD on subscribe, SREM on unsubscribe)
|
|
||||||
3. Core refreshes `{prefix}:instances:{id}` Redis key every 2s (5s TTL)
|
|
||||||
4. Coordinator watches stream; on drop → immediate failover
|
|
||||||
5. Failover: get `SMEMBERS {id}:games`, call `BatchResubscribeGames` on healthy cores
|
|
||||||
|
|
||||||
**Key Insight**: Three detection signals (gRPC stream, Redis TTL, k8s watch), but **gRPC stream drop is primary** (50–200ms detection).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proto Compilation Fix
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Scala code imports `de.nowchess.coordinator.HeartbeatFrame` but proto plugin generates classes Gradle doesn't make visible.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
Quarkus gRPC plugin generates Java stubs in `build/generated/sources/protobuf/java/` during `quarkusGenerateCode` task. These are compiled to `.class` files but Scala compiler can't find them at compile time because they're not on Scala's classpath early enough.
|
|
||||||
|
|
||||||
**Fix**: Add proto compilation order dependency in both modules:
|
|
||||||
|
|
||||||
**modules/coordinator/build.gradle.kts** and **modules/core/build.gradle.kts**:
|
|
||||||
```gradle
|
|
||||||
tasks.compileScala {
|
|
||||||
dependsOn(tasks.named("compileJava")) // Ensures Java stubs compiled first
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also ensure proto is on sourceSets:
|
|
||||||
```gradle
|
|
||||||
sourceSets {
|
|
||||||
main {
|
|
||||||
proto {
|
|
||||||
srcDir("src/main/proto")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Quarkus v3.x should handle this automatically, but explicit dependency helps.
|
|
||||||
|
|
||||||
### Alternative: Use Generated Java Classes Directly
|
|
||||||
If proto stubs still not found, import **exactly as generated**:
|
|
||||||
```scala
|
|
||||||
// Don't try to import individual types
|
|
||||||
import de.nowchess.coordinator.{
|
|
||||||
CoordinatorServiceGrpc,
|
|
||||||
HeartbeatFrame,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instead, use full paths or check actual generated names
|
|
||||||
val frame = de.nowchess.coordinator.HeartbeatFrame.newBuilder()
|
|
||||||
.setInstanceId("...")
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `./gradlew clean modules:coordinator:compileJava` to regenerate and inspect `build/generated/sources/protobuf/java/de/nowchess/coordinator/` to see actual class names.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality Issues (Non-Blocking)
|
|
||||||
|
|
||||||
**Fix in coordinator services** (already have `= _` deprecation warnings):
|
|
||||||
|
|
||||||
```scala
|
|
||||||
// OLD
|
|
||||||
@Inject var redissonClient: RedissonClient = _
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
import scala.compiletime.uninitialized
|
|
||||||
@Inject var redissonClient: RedissonClient = uninitialized
|
|
||||||
```
|
|
||||||
|
|
||||||
**Jakarta optional injection**:
|
|
||||||
```scala
|
|
||||||
// Old (doesn't work)
|
|
||||||
@Inject(optional = true) var kubeClient: KubernetesClient = _
|
|
||||||
|
|
||||||
// Better (use null check)
|
|
||||||
@Inject var kubeClient: KubernetesClient = null
|
|
||||||
if (kubeClient != null) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method params in private helpers**: Remove unused params in `scaleUp()`, `scaleDown()`, `rebalance()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Missing Implementation (Phase 2)
|
|
||||||
|
|
||||||
### 1. **InstanceHeartbeatService** (DONE, needs testing)
|
|
||||||
- [x] Startup: generate instanceId, open gRPC stream, schedule heartbeats
|
|
||||||
- [x] Every 200ms: send `HeartbeatFrame` via stream
|
|
||||||
- [x] Every 2s: refresh Redis TTL on `{prefix}:instances:{id}`
|
|
||||||
- [x] `addGameSubscription(gameId)` → `SADD {id}:games {gameId}`
|
|
||||||
- [x] `removeGameSubscription(gameId)` → `SREM {id}:games {gameId}`
|
|
||||||
- [x] Shutdown: cleanup Redis + stream
|
|
||||||
- [ ] **Test**: Kill core JVM, verify coordinator detects within 300ms
|
|
||||||
|
|
||||||
### 2. **Coordinator HealthMonitor** (skeleton done)
|
|
||||||
- [ ] Watch gRPC streams: on `onError()` or `onCompleted()`, mark instance DEAD
|
|
||||||
- [ ] Fallback: poll Redis heartbeat TTL expiry every 5s
|
|
||||||
- [ ] Fallback: k8s pod watch for label `app=nowchess-core`, detect NotReady status
|
|
||||||
- [ ] Decision: if gRPC drop → immediate failover (no wait)
|
|
||||||
|
|
||||||
### 3. **Coordinator FailoverService** (partial)
|
|
||||||
```scala
|
|
||||||
def onInstanceStreamDropped(instanceId: String): Unit =
|
|
||||||
val gameIds = SMEMBERS "{prefix}:instance:{id}:games"
|
|
||||||
val healthyInstances = getAllHealthyInstances()
|
|
||||||
|
|
||||||
// Distribute games round-robin by load
|
|
||||||
gameIds.grouped(gameIds.size / healthyInstances.size).zipWithIndex.foreach {
|
|
||||||
case (batch, idx) =>
|
|
||||||
val target = healthyInstances(idx % healthyInstances.size)
|
|
||||||
call target.grpcStub.batchResubscribeGames(batch)
|
|
||||||
}
|
|
||||||
|
|
||||||
DEL "{prefix}:instance:{id}:games"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Coordinator gRPC Client Stubs** (need manual integration)
|
|
||||||
Create **modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala**:
|
|
||||||
```scala
|
|
||||||
@ApplicationScoped
|
|
||||||
class CoreGrpcClient:
|
|
||||||
@GrpcClient("core-grpc")
|
|
||||||
private var coreStub: CoordinatorServiceGrpc.CoordinatorServiceStub = uninitialized
|
|
||||||
|
|
||||||
def batchResubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
|
|
||||||
// Build request, call via dynamic stub to (host, port)
|
|
||||||
val response = coreStub.batchResubscribeGames(...)
|
|
||||||
response.getSubscribedCount
|
|
||||||
```
|
|
||||||
|
|
||||||
Need to add dynamic gRPC client (Quarkus doesn't support runtime host:port changing by default). **Workaround**: Use `io.grpc:grpc-netty-shaded` + `ManagedChannel` directly:
|
|
||||||
```scala
|
|
||||||
val channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()
|
|
||||||
val stub = CoordinatorServiceGrpc.newStub(channel)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **LoadBalancer.rebalance()** (stub → full impl)
|
|
||||||
```scala
|
|
||||||
def rebalance(): Unit =
|
|
||||||
val instances = listInstancesFromRedis()
|
|
||||||
val loads = instances.map(_.subscriptionCount)
|
|
||||||
val mean = loads.sum / loads.size
|
|
||||||
|
|
||||||
val overloaded = instances.filter(_.subscriptionCount > maxGamesPerCore)
|
|
||||||
.sortByDescending(_.subscriptionCount)
|
|
||||||
val underloaded = instances.filter(_.subscriptionCount < mean * 0.8)
|
|
||||||
.sortBy(_.subscriptionCount)
|
|
||||||
|
|
||||||
overloaded.foreach { over =>
|
|
||||||
val excess = over.subscriptionCount - targetLoad
|
|
||||||
underloaded.headOption.foreach { under =>
|
|
||||||
val toMove = getGamesToMove(over.instanceId, excess)
|
|
||||||
call over.coreGrpc.unsubscribeGames(toMove)
|
|
||||||
call under.coreGrpc.batchResubscribeGames(toMove)
|
|
||||||
// Update Redis sets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **AutoScaler** (stub → k8s API calls)
|
|
||||||
```scala
|
|
||||||
def scaleUp(): Unit =
|
|
||||||
if (kubeClient != null && config.autoScaleEnabled) {
|
|
||||||
val rollout = kubeClient.resources(classOf[Rollout])
|
|
||||||
.inNamespace(config.k8sNamespace)
|
|
||||||
.withName(config.k8sRolloutName)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
val newReplicas = rollout.getSpec.getReplicas + 1
|
|
||||||
rollout.getSpec.setReplicas(newReplicas)
|
|
||||||
kubeClient.resources(classOf[Rollout])
|
|
||||||
.inNamespace(config.k8sNamespace)
|
|
||||||
.withName(config.k8sRolloutName)
|
|
||||||
.createOrReplace(rollout)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires: `io.fabric8:kubernetes-client:6.13.0` (already in build.gradle.kts).
|
|
||||||
|
|
||||||
### 7. **CacheEvictionManager** (stub → full impl)
|
|
||||||
```scala
|
|
||||||
def evictStaleGames(): Unit =
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val keys = KEYS "{prefix}:game:entry:*"
|
|
||||||
|
|
||||||
keys.foreach { key =>
|
|
||||||
val bucket = redissonClient.getBucket[String](key)
|
|
||||||
val json = bucket.get()
|
|
||||||
val lastUpdated = extractTimestamp(json) // Parse JSON
|
|
||||||
|
|
||||||
if (now - lastUpdated > config.gameIdleThreshold.toMillis) {
|
|
||||||
val gameId = key.stripPrefix(...)
|
|
||||||
val instance = findInstanceWithGame(gameId)
|
|
||||||
|
|
||||||
instance.foreach { inst =>
|
|
||||||
call inst.coreGrpc.evictGames(List(gameId))
|
|
||||||
}
|
|
||||||
bucket.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. **CoordinatorGrpcServer HeartbeatStream** (stub → full impl)
|
|
||||||
```scala
|
|
||||||
override def heartbeatStream(
|
|
||||||
responseObserver: StreamObserver[CoordinatorCommand]
|
|
||||||
): StreamObserver[HeartbeatFrame] =
|
|
||||||
new StreamObserver[HeartbeatFrame]:
|
|
||||||
private var lastInstanceId = ""
|
|
||||||
|
|
||||||
override def onNext(frame: HeartbeatFrame): Unit =
|
|
||||||
lastInstanceId = frame.getInstanceId
|
|
||||||
instanceRegistry.updateInstanceFromRedis(lastInstanceId)
|
|
||||||
|
|
||||||
override def onError(t: Throwable): Unit =
|
|
||||||
log.warnf(t, "Stream error for %s", lastInstanceId)
|
|
||||||
failoverService.onInstanceStreamDropped(lastInstanceId)
|
|
||||||
|
|
||||||
override def onCompleted(): Unit =
|
|
||||||
log.infof("Stream completed for %s", lastInstanceId)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Compile with proto fix
|
|
||||||
- [ ] Start core + coordinator
|
|
||||||
- [ ] Create game, subscribe core
|
|
||||||
- [ ] Watch `redis-cli SMEMBERS nowchess:instance:{id}:games` → game appears
|
|
||||||
- [ ] Kill core JVM via `kill -9`
|
|
||||||
- [ ] Verify coordinator log shows "stream error" within 200ms
|
|
||||||
- [ ] Verify second core receives `batchResubscribeGames` call within 300ms total
|
|
||||||
- [ ] Create second core, rebalance load, verify games migrate
|
|
||||||
- [ ] Scale up: verify Argo Rollout replica count increases
|
|
||||||
- [ ] 45min idle game: verify coordinator calls `evictGames`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Checklist
|
|
||||||
|
|
||||||
✅ **Created**:
|
|
||||||
- `modules/coordinator/build.gradle.kts`
|
|
||||||
- `modules/coordinator/src/main/proto/coordinator_service.proto`
|
|
||||||
- `modules/coordinator/src/main/resources/application.yml`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/dto/InstanceMetadata.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala`
|
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala`
|
|
||||||
- `modules/core/src/main/proto/coordinator_service.proto`
|
|
||||||
- `modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala`
|
|
||||||
- `modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala`
|
|
||||||
|
|
||||||
✅ **Modified**:
|
|
||||||
- `settings.gradle.kts` → added `modules:coordinator`
|
|
||||||
- `modules/core/src/main/resources/application.yml` → added coordinator gRPC client + heartbeat config
|
|
||||||
- `modules/core/build.gradle.kts` → (no changes, proto handled by quarkus-grpc)
|
|
||||||
- `modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala` → added InstanceHeartbeatService injection, SADD/SREM, batch ops
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps (New Session)
|
|
||||||
|
|
||||||
1. Run `./gradlew clean modules:coordinator:compileScala` with proto fix
|
|
||||||
2. Finish gRPC client stubs (Rollout, managed channels)
|
|
||||||
3. Implement `FailoverService.distributeGames()` with actual core gRPC calls
|
|
||||||
4. Implement `LoadBalancer.rebalance()` with game migration
|
|
||||||
5. Implement `AutoScaler` with k8s API
|
|
||||||
6. Implement `CacheEvictionManager` with timestamp parsing
|
|
||||||
7. Run integration tests (manual or `@QuarkusTest`)
|
|
||||||
8. Benchmark: create 5000 games, kill 1 core, measure failover time
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions (Record for Future)
|
|
||||||
|
|
||||||
- **GRPC stream as primary**: TCP-level detection <200ms vs polling/TTL 5-30s trade-off
|
|
||||||
- **Redis game sets**: SADD/SREM for O(1) lookup vs scanning Redis per failover
|
|
||||||
- **Argo Rollouts not StatefulSet**: Respects canary/blue-green; patch via Fabric8 `GenericKubernetesResource`
|
|
||||||
- **Batch gRPC calls**: One call per target core vs 1:1 calls per game (saves RPC overhead)
|
|
||||||
- **No persistent subscriptions**: On coordinator restart, gRPC reconnects auto-trigger resubscribe; best-effort is OK
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Gaps
|
|
||||||
|
|
||||||
- Error handling: what if `batchResubscribeGames` fails? Retry? Partial migration? (Add circuit breaker)
|
|
||||||
- Coordinator HA: single instance. Add Quorum or K8s deployment with multiple replicas + leader election if needed
|
|
||||||
- Metrics: no Prometheus exports yet (add via `quarkus-micrometer`)
|
|
||||||
- Monitoring: logs only, no alerts on failover latency SLA violation
|
|
||||||
@@ -51,6 +51,8 @@ val coverageExclusions = listOf(
|
|||||||
"**/ws/src/main/scala/de/nowchess/ws/config/**",
|
"**/ws/src/main/scala/de/nowchess/ws/config/**",
|
||||||
// GameWebSocketResource in core — replaced by ws module
|
// GameWebSocketResource in core — replaced by ws module
|
||||||
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
|
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
|
||||||
|
// Coordinator infrastructure — gRPC, microservice orchestration
|
||||||
|
"**/coordinator/src/main/scala/**",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
package de.nowchess.account.config
|
package de.nowchess.account.config
|
||||||
|
|
||||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
|
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
|
||||||
import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount, Challenge, ChallengeColor, ChallengeStatus, DeclineReason, TimeControl}
|
import de.nowchess.account.domain.{
|
||||||
|
BotAccount,
|
||||||
|
Challenge,
|
||||||
|
ChallengeColor,
|
||||||
|
ChallengeStatus,
|
||||||
|
DeclineReason,
|
||||||
|
OfficialBotAccount,
|
||||||
|
TimeControl,
|
||||||
|
UserAccount,
|
||||||
|
}
|
||||||
import de.nowchess.account.dto.*
|
import de.nowchess.account.dto.*
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -18,7 +18,7 @@ class AlreadyLoggedInFilter extends ContainerRequestFilter:
|
|||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
override def filter(context: ContainerRequestContext): Unit =
|
override def filter(context: ContainerRequestContext): Unit =
|
||||||
val path = context.getUriInfo.getPath
|
val path = context.getUriInfo.getPath
|
||||||
val method = context.getMethod
|
val method = context.getMethod
|
||||||
|
|
||||||
if isProtectedEndpoint(path, method) && isAuthenticated then
|
if isProtectedEndpoint(path, method) && isAuthenticated then
|
||||||
@@ -26,14 +26,15 @@ class AlreadyLoggedInFilter extends ContainerRequestFilter:
|
|||||||
Response
|
Response
|
||||||
.status(Response.Status.BAD_REQUEST)
|
.status(Response.Status.BAD_REQUEST)
|
||||||
.entity("""{"error":"Already logged in"}""")
|
.entity("""{"error":"Already logged in"}""")
|
||||||
.build()
|
.build(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private def isAuthenticated: Boolean =
|
private def isAuthenticated: Boolean =
|
||||||
// scalafix:off DisableSyntax.null
|
// scalafix:off DisableSyntax.null
|
||||||
try jwt.getName != null
|
try jwt.getName != null
|
||||||
catch case _ => false
|
catch
|
||||||
// scalafix:on DisableSyntax.null
|
case _ => false
|
||||||
|
// scalafix:on DisableSyntax.null
|
||||||
|
|
||||||
private def isProtectedEndpoint(path: String, method: String): Boolean =
|
private def isProtectedEndpoint(path: String, method: String): Boolean =
|
||||||
(path.contains("/api/account") || path.contains("/account")) &&
|
(path.contains("/api/account") || path.contains("/account")) &&
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.account.repository
|
package de.nowchess.account.repository
|
||||||
|
|
||||||
import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount}
|
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.persistence.EntityManager
|
import jakarta.persistence.EntityManager
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.nowchess.account.resource
|
package de.nowchess.account.resource
|
||||||
|
|
||||||
import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount}
|
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||||
import de.nowchess.account.dto.*
|
import de.nowchess.account.dto.*
|
||||||
import de.nowchess.account.error.AccountError
|
import de.nowchess.account.error.AccountError
|
||||||
import de.nowchess.account.service.AccountService
|
import de.nowchess.account.service.AccountService
|
||||||
@@ -100,7 +100,7 @@ class AccountResource:
|
|||||||
@RolesAllowed(Array("**"))
|
@RolesAllowed(Array("**"))
|
||||||
def listBotAccounts(): Response =
|
def listBotAccounts(): Response =
|
||||||
val ownerId = UUID.fromString(jwt.getSubject)
|
val ownerId = UUID.fromString(jwt.getSubject)
|
||||||
val bots = accountService.getBotAccounts(ownerId)
|
val bots = accountService.getBotAccounts(ownerId)
|
||||||
Response.ok(bots.map(toBotDto)).build()
|
Response.ok(bots.map(toBotDto)).build()
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package de.nowchess.account.service
|
package de.nowchess.account.service
|
||||||
|
|
||||||
import de.nowchess.account.domain.{UserAccount, BotAccount, OfficialBotAccount}
|
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||||
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
|
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
|
||||||
import de.nowchess.account.error.AccountError
|
import de.nowchess.account.error.AccountError
|
||||||
import de.nowchess.account.repository.{UserAccountRepository, BotAccountRepository, OfficialBotAccountRepository}
|
import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
|
||||||
import io.quarkus.elytron.security.common.BcryptUtil
|
import io.quarkus.elytron.security.common.BcryptUtil
|
||||||
import io.smallrye.jwt.build.Jwt
|
import io.smallrye.jwt.build.Jwt
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
@@ -31,7 +31,8 @@ class AccountService:
|
|||||||
@Transactional
|
@Transactional
|
||||||
def register(req: RegisterRequest): Either[AccountError, UserAccount] =
|
def register(req: RegisterRequest): Either[AccountError, UserAccount] =
|
||||||
if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
|
if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
|
||||||
else if userAccountRepository.findByEmail(req.email).isDefined then Left(AccountError.EmailAlreadyRegistered(req.email))
|
else if userAccountRepository.findByEmail(req.email).isDefined then
|
||||||
|
Left(AccountError.EmailAlreadyRegistered(req.email))
|
||||||
else
|
else
|
||||||
val account = new UserAccount()
|
val account = new UserAccount()
|
||||||
account.username = req.username
|
account.username = req.username
|
||||||
@@ -53,7 +54,7 @@ class AccountService:
|
|||||||
.issuer("nowchess")
|
.issuer("nowchess")
|
||||||
.subject(account.id.toString)
|
.subject(account.id.toString)
|
||||||
.claim("username", account.username)
|
.claim("username", account.username)
|
||||||
.sign()
|
.sign(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def findByUsername(username: String): Option[UserAccount] =
|
def findByUsername(username: String): Option[UserAccount] =
|
||||||
@@ -83,7 +84,7 @@ class AccountService:
|
|||||||
|
|
||||||
def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] =
|
def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] =
|
||||||
botAccountRepository.findById(botId) match
|
botAccountRepository.findById(botId) match
|
||||||
case None => Some(None)
|
case None => Some(None)
|
||||||
case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId))
|
case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId))
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import de.nowchess.account.dto.{
|
|||||||
TimeControlDto,
|
TimeControlDto,
|
||||||
}
|
}
|
||||||
import de.nowchess.account.error.ChallengeError
|
import de.nowchess.account.error.ChallengeError
|
||||||
import de.nowchess.account.repository.{UserAccountRepository, ChallengeRepository}
|
import de.nowchess.account.repository.{ChallengeRepository, UserAccountRepository}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
@@ -46,7 +46,7 @@ class ChallengeService:
|
|||||||
@Transactional
|
@Transactional
|
||||||
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
|
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
|
||||||
for
|
for
|
||||||
destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
|
destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
|
||||||
challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
|
challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
|
||||||
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
|
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
|
||||||
_ <- Either.cond(
|
_ <- Either.cond(
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ tasks.withType<ScalaCompile> {
|
|||||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named("compileScoverageJava").configure {
|
||||||
|
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(ScalaCompile::class).configureEach {
|
||||||
|
if (name == "compileScoverageScala") {
|
||||||
|
source = source.asFileTree.matching {
|
||||||
|
exclude("**/grpc/*.scala")
|
||||||
|
exclude("**/service/*.scala")
|
||||||
|
exclude("**/resource/*.scala")
|
||||||
|
exclude("**/config/*.scala")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val quarkusPlatformGroupId: String by project
|
val quarkusPlatformGroupId: String by project
|
||||||
val quarkusPlatformArtifactId: String by project
|
val quarkusPlatformArtifactId: String by project
|
||||||
val quarkusPlatformVersion: String by project
|
val quarkusPlatformVersion: String by project
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
option java_package = "de.nowchess.coordinator.proto";
|
||||||
package de.nowchess.coordinator;
|
option java_multiple_files = true;
|
||||||
|
option java_outer_classname = "CoordinatorServiceProto";
|
||||||
|
|
||||||
service CoordinatorService {
|
service CoordinatorService {
|
||||||
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.nowchess.coordinator.config
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.inject.Produces
|
||||||
|
import io.fabric8.kubernetes.client.KubernetesClientBuilder
|
||||||
|
import io.fabric8.kubernetes.client.KubernetesClient
|
||||||
|
import org.redisson.Redisson
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.config.Config
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class BeansProducer:
|
||||||
|
@Inject
|
||||||
|
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
|
||||||
|
private var redisHost: String = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
|
||||||
|
private var redisPort: Int = uninitialized
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
@ApplicationScoped
|
||||||
|
def redissonClient: RedissonClient =
|
||||||
|
val config = Config()
|
||||||
|
config.useSingleServer().setAddress(s"redis://$redisHost:$redisPort")
|
||||||
|
Redisson.create(config)
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
@ApplicationScoped
|
||||||
|
def kubernetesClient: KubernetesClient =
|
||||||
|
KubernetesClientBuilder().build()
|
||||||
+16
-16
@@ -4,20 +4,20 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
case class InstanceMetadata(
|
case class InstanceMetadata(
|
||||||
@JsonProperty("instanceId")
|
@JsonProperty("instanceId")
|
||||||
instanceId: String,
|
instanceId: String,
|
||||||
@JsonProperty("hostname")
|
@JsonProperty("hostname")
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@JsonProperty("httpPort")
|
@JsonProperty("httpPort")
|
||||||
httpPort: Int,
|
httpPort: Int,
|
||||||
@JsonProperty("grpcPort")
|
@JsonProperty("grpcPort")
|
||||||
grpcPort: Int,
|
grpcPort: Int,
|
||||||
@JsonProperty("subscriptionCount")
|
@JsonProperty("subscriptionCount")
|
||||||
subscriptionCount: Int,
|
subscriptionCount: Int,
|
||||||
@JsonProperty("localCacheSize")
|
@JsonProperty("localCacheSize")
|
||||||
localCacheSize: Int,
|
localCacheSize: Int,
|
||||||
@JsonProperty("lastHeartbeat")
|
@JsonProperty("lastHeartbeat")
|
||||||
lastHeartbeat: String,
|
lastHeartbeat: String,
|
||||||
@JsonProperty("state")
|
@JsonProperty("state")
|
||||||
state: String = "HEALTHY"
|
state: String = "HEALTHY",
|
||||||
)
|
)
|
||||||
|
|||||||
+34
-45
@@ -1,27 +1,17 @@
|
|||||||
package de.nowchess.coordinator.grpc
|
package de.nowchess.coordinator.grpc
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import io.quarkus.grpc.GrpcService
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import de.nowchess.coordinator.service.{FailoverService, InstanceRegistry}
|
import de.nowchess.coordinator.service.{FailoverService, InstanceRegistry}
|
||||||
import de.nowchess.coordinator.CoordinatorServiceGrpc
|
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||||
import de.nowchess.coordinator.{
|
|
||||||
HeartbeatFrame,
|
|
||||||
CoordinatorCommand,
|
|
||||||
BatchResubscribeRequest,
|
|
||||||
BatchResubscribeResponse,
|
|
||||||
UnsubscribeGamesRequest,
|
|
||||||
UnsubscribeGamesResponse,
|
|
||||||
EvictGamesRequest,
|
|
||||||
EvictGamesResponse,
|
|
||||||
DrainInstanceRequest,
|
|
||||||
DrainInstanceResponse
|
|
||||||
}
|
|
||||||
import io.grpc.stub.StreamObserver
|
import io.grpc.stub.StreamObserver
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
|
||||||
@ApplicationScoped
|
@GrpcService
|
||||||
|
@Singleton
|
||||||
class CoordinatorGrpcServer extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
|
class CoordinatorGrpcServer extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject
|
@Inject
|
||||||
@@ -32,78 +22,77 @@ class CoordinatorGrpcServer extends CoordinatorServiceGrpc.CoordinatorServiceImp
|
|||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val mapper = ObjectMapper()
|
private val mapper = ObjectMapper()
|
||||||
private val log = Logger.getLogger(classOf[CoordinatorGrpcServer])
|
private val log = Logger.getLogger(classOf[CoordinatorGrpcServer])
|
||||||
|
|
||||||
override def heartbeatStream(
|
override def heartbeatStream(
|
||||||
responseObserver: StreamObserver[CoordinatorCommand]
|
responseObserver: StreamObserver[CoordinatorCommand],
|
||||||
): StreamObserver[HeartbeatFrame] =
|
): StreamObserver[HeartbeatFrame] =
|
||||||
new StreamObserver[HeartbeatFrame]:
|
new StreamObserver[HeartbeatFrame]:
|
||||||
private var lastInstanceId = ""
|
private var lastInstanceId = ""
|
||||||
|
|
||||||
override def onNext(frame: HeartbeatFrame): Unit =
|
override def onNext(frame: HeartbeatFrame): Unit =
|
||||||
lastInstanceId = frame.getInstanceId
|
lastInstanceId = frame.getInstanceId
|
||||||
// Update instance registry with heartbeat data
|
try
|
||||||
val metadata = de.nowchess.coordinator.dto.InstanceMetadata(
|
instanceRegistry.updateInstanceFromRedis(frame.getInstanceId)
|
||||||
instanceId = frame.getInstanceId,
|
log.debugf(
|
||||||
hostname = frame.getHostname,
|
"Received heartbeat from %s with %d subscriptions",
|
||||||
httpPort = frame.getHttpPort,
|
frame.getInstanceId,
|
||||||
grpcPort = frame.getGrpcPort,
|
frame.getSubscriptionCount,
|
||||||
subscriptionCount = frame.getSubscriptionCount,
|
)
|
||||||
localCacheSize = frame.getLocalCacheSize,
|
catch
|
||||||
lastHeartbeat = java.time.Instant.ofEpochMilli(frame.getTimestampMillis).toString,
|
case ex: Exception =>
|
||||||
state = "HEALTHY"
|
log.warnf(ex, "Failed to process heartbeat from %s", frame.getInstanceId)
|
||||||
)
|
|
||||||
// Store in registry (placeholder for actual storage)
|
|
||||||
log.debugf("Received heartbeat from %s with %d subscriptions",
|
|
||||||
frame.getInstanceId, frame.getSubscriptionCount)
|
|
||||||
|
|
||||||
override def onError(t: Throwable): Unit =
|
override def onError(t: Throwable): Unit =
|
||||||
log.warnf(t, "Heartbeat stream error for instance %s", lastInstanceId)
|
log.warnf(t, "Heartbeat stream error for instance %s", lastInstanceId)
|
||||||
if lastInstanceId.nonEmpty then
|
if lastInstanceId.nonEmpty then failoverService.onInstanceStreamDropped(lastInstanceId)
|
||||||
failoverService.onInstanceStreamDropped(lastInstanceId)
|
|
||||||
|
|
||||||
override def onCompleted: Unit =
|
override def onCompleted: Unit =
|
||||||
log.infof("Heartbeat stream completed for instance %s", lastInstanceId)
|
log.infof("Heartbeat stream completed for instance %s", lastInstanceId)
|
||||||
|
|
||||||
override def batchResubscribeGames(
|
override def batchResubscribeGames(
|
||||||
request: BatchResubscribeRequest,
|
request: BatchResubscribeRequest,
|
||||||
responseObserver: StreamObserver[BatchResubscribeResponse]
|
responseObserver: StreamObserver[BatchResubscribeResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
log.infof("Batch resubscribe request for %d games", request.getGameIdsList.size())
|
log.infof("Batch resubscribe request for %d games", request.getGameIdsList.size())
|
||||||
val response = BatchResubscribeResponse.newBuilder()
|
val response = BatchResubscribeResponse
|
||||||
|
.newBuilder()
|
||||||
.setSubscribedCount(request.getGameIdsList.size())
|
.setSubscribedCount(request.getGameIdsList.size())
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def unsubscribeGames(
|
override def unsubscribeGames(
|
||||||
request: UnsubscribeGamesRequest,
|
request: UnsubscribeGamesRequest,
|
||||||
responseObserver: StreamObserver[UnsubscribeGamesResponse]
|
responseObserver: StreamObserver[UnsubscribeGamesResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
log.infof("Unsubscribe request for %d games", request.getGameIdsList.size())
|
log.infof("Unsubscribe request for %d games", request.getGameIdsList.size())
|
||||||
val response = UnsubscribeGamesResponse.newBuilder()
|
val response = UnsubscribeGamesResponse
|
||||||
|
.newBuilder()
|
||||||
.setUnsubscribedCount(request.getGameIdsList.size())
|
.setUnsubscribedCount(request.getGameIdsList.size())
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def evictGames(
|
override def evictGames(
|
||||||
request: EvictGamesRequest,
|
request: EvictGamesRequest,
|
||||||
responseObserver: StreamObserver[EvictGamesResponse]
|
responseObserver: StreamObserver[EvictGamesResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
log.infof("Evict request for %d games", request.getGameIdsList.size())
|
log.infof("Evict request for %d games", request.getGameIdsList.size())
|
||||||
val response = EvictGamesResponse.newBuilder()
|
val response = EvictGamesResponse
|
||||||
|
.newBuilder()
|
||||||
.setEvictedCount(request.getGameIdsList.size())
|
.setEvictedCount(request.getGameIdsList.size())
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def drainInstance(
|
override def drainInstance(
|
||||||
request: DrainInstanceRequest,
|
request: DrainInstanceRequest,
|
||||||
responseObserver: StreamObserver[DrainInstanceResponse]
|
responseObserver: StreamObserver[DrainInstanceResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
log.info("Drain instance request")
|
log.info("Drain instance request")
|
||||||
val response = DrainInstanceResponse.newBuilder()
|
val response = DrainInstanceResponse
|
||||||
|
.newBuilder()
|
||||||
.setGamesMigrated(0)
|
.setGamesMigrated(0)
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.nowchess.coordinator.grpc
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import org.jboss.logging.Logger
|
||||||
|
import io.grpc.ManagedChannel
|
||||||
|
import io.grpc.ManagedChannelBuilder
|
||||||
|
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class CoreGrpcClient:
|
||||||
|
private val log = Logger.getLogger(classOf[CoreGrpcClient])
|
||||||
|
|
||||||
|
def batchResubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||||
|
val channel = createChannel(host, port)
|
||||||
|
try
|
||||||
|
val stub = CoordinatorServiceGrpc.newStub(channel)
|
||||||
|
val request = BatchResubscribeRequest
|
||||||
|
.newBuilder()
|
||||||
|
.addAllGameIds(gameIds.asJava)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val latch = new java.util.concurrent.CountDownLatch(1)
|
||||||
|
var result = 0
|
||||||
|
|
||||||
|
stub.batchResubscribeGames(
|
||||||
|
request,
|
||||||
|
new io.grpc.stub.StreamObserver[BatchResubscribeResponse]:
|
||||||
|
override def onNext(response: BatchResubscribeResponse): Unit =
|
||||||
|
result = response.getSubscribedCount
|
||||||
|
|
||||||
|
override def onError(t: Throwable): Unit =
|
||||||
|
log.warnf(t, "batchResubscribeGames RPC failed for %s:%d", host, port)
|
||||||
|
latch.countDown()
|
||||||
|
|
||||||
|
override def onCompleted(): Unit =
|
||||||
|
latch.countDown(),
|
||||||
|
)
|
||||||
|
|
||||||
|
latch.await()
|
||||||
|
result
|
||||||
|
finally channel.shutdown()
|
||||||
|
|
||||||
|
def unsubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||||
|
val channel = createChannel(host, port)
|
||||||
|
try
|
||||||
|
val stub = CoordinatorServiceGrpc.newBlockingStub(channel)
|
||||||
|
val request = UnsubscribeGamesRequest
|
||||||
|
.newBuilder()
|
||||||
|
.addAllGameIds(gameIds.asJava)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = stub.unsubscribeGames(request)
|
||||||
|
response.getUnsubscribedCount
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "unsubscribeGames RPC failed for %s:%d", host, port)
|
||||||
|
0
|
||||||
|
finally channel.shutdown()
|
||||||
|
|
||||||
|
def evictGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||||
|
val channel = createChannel(host, port)
|
||||||
|
try
|
||||||
|
val stub = CoordinatorServiceGrpc.newBlockingStub(channel)
|
||||||
|
val request = EvictGamesRequest
|
||||||
|
.newBuilder()
|
||||||
|
.addAllGameIds(gameIds.asJava)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = stub.evictGames(request)
|
||||||
|
response.getEvictedCount
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "evictGames RPC failed for %s:%d", host, port)
|
||||||
|
0
|
||||||
|
finally channel.shutdown()
|
||||||
|
|
||||||
|
private def createChannel(host: String, port: Int): ManagedChannel =
|
||||||
|
ManagedChannelBuilder
|
||||||
|
.forAddress(host, port)
|
||||||
|
.usePlaintext()
|
||||||
|
.build()
|
||||||
+16
-16
@@ -6,7 +6,7 @@ import jakarta.ws.rs.*
|
|||||||
import jakarta.ws.rs.core.MediaType
|
import jakarta.ws.rs.core.MediaType
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import de.nowchess.coordinator.service.{InstanceRegistry, LoadBalancer, AutoScaler, FailoverService}
|
import de.nowchess.coordinator.service.{AutoScaler, FailoverService, InstanceRegistry, LoadBalancer}
|
||||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@ class CoordinatorResource:
|
|||||||
@Path("/metrics")
|
@Path("/metrics")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def getMetrics: MetricsDto =
|
def getMetrics: MetricsDto =
|
||||||
val instances = instanceRegistry.getAllInstances
|
val instances = instanceRegistry.getAllInstances
|
||||||
val loads = instances.map(_.subscriptionCount)
|
val loads = instances.map(_.subscriptionCount)
|
||||||
val totalGames = loads.sum
|
val totalGames = loads.sum
|
||||||
val avgLoad = if instances.nonEmpty then loads.sum.toDouble / instances.size else 0.0
|
val avgLoad = if instances.nonEmpty then loads.sum.toDouble / instances.size else 0.0
|
||||||
val maxLoad = if loads.nonEmpty then loads.max else 0
|
val maxLoad = if loads.nonEmpty then loads.max else 0
|
||||||
val minLoad = if loads.nonEmpty then loads.min else 0
|
val minLoad = if loads.nonEmpty then loads.min else 0
|
||||||
|
|
||||||
MetricsDto(
|
MetricsDto(
|
||||||
totalInstances = instances.size,
|
totalInstances = instances.size,
|
||||||
@@ -54,7 +54,7 @@ class CoordinatorResource:
|
|||||||
avgGamesPerCore = avgLoad,
|
avgGamesPerCore = avgLoad,
|
||||||
maxGamesPerCore = maxLoad,
|
maxGamesPerCore = maxLoad,
|
||||||
minGamesPerCore = minLoad,
|
minGamesPerCore = minLoad,
|
||||||
instances = instances
|
instances = instances,
|
||||||
)
|
)
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -62,7 +62,7 @@ class CoordinatorResource:
|
|||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def triggerRebalance: scala.collection.Map[String, String] =
|
def triggerRebalance: scala.collection.Map[String, String] =
|
||||||
log.info("Manual rebalance triggered")
|
log.info("Manual rebalance triggered")
|
||||||
loadBalancer.rebalance()
|
loadBalancer.rebalance
|
||||||
Map("status" -> "rebalance_started")
|
Map("status" -> "rebalance_started")
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -88,12 +88,12 @@ class CoordinatorResource:
|
|||||||
Map("status" -> "scale_down_started")
|
Map("status" -> "scale_down_started")
|
||||||
|
|
||||||
case class MetricsDto(
|
case class MetricsDto(
|
||||||
totalInstances: Int,
|
totalInstances: Int,
|
||||||
healthyInstances: Int,
|
healthyInstances: Int,
|
||||||
deadInstances: Int,
|
deadInstances: Int,
|
||||||
totalGames: Int,
|
totalGames: Int,
|
||||||
avgGamesPerCore: Double,
|
avgGamesPerCore: Double,
|
||||||
maxGamesPerCore: Int,
|
maxGamesPerCore: Int,
|
||||||
minGamesPerCore: Int,
|
minGamesPerCore: Int,
|
||||||
instances: List[InstanceMetadata]
|
instances: List[InstanceMetadata],
|
||||||
)
|
)
|
||||||
|
|||||||
+71
-19
@@ -5,24 +5,24 @@ import jakarta.inject.Inject
|
|||||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||||
import io.fabric8.kubernetes.client.KubernetesClient
|
import io.fabric8.kubernetes.client.KubernetesClient
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class AutoScaler:
|
class AutoScaler:
|
||||||
@Inject(optional = true)
|
@Inject
|
||||||
private var kubeClient: KubernetesClient = _
|
private var kubeClient: KubernetesClient = null
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var config: CoordinatorConfig = _
|
private var config: CoordinatorConfig = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var instanceRegistry: InstanceRegistry = _
|
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[AutoScaler])
|
private val log = Logger.getLogger(classOf[AutoScaler])
|
||||||
private var lastScaleTime = 0L
|
private var lastScaleTime = 0L
|
||||||
|
|
||||||
def checkAndScale: Unit =
|
def checkAndScale: Unit =
|
||||||
if !config.autoScaleEnabled then
|
if !config.autoScaleEnabled then return
|
||||||
return
|
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if now - lastScaleTime < 120000 then // 2 minute backoff
|
if now - lastScaleTime < 120000 then // 2 minute backoff
|
||||||
@@ -31,28 +31,80 @@ class AutoScaler:
|
|||||||
val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
|
val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
|
||||||
if instances.isEmpty then return
|
if instances.isEmpty then return
|
||||||
|
|
||||||
val avgLoad = instances.map(_.subscriptionCount).sum.toDouble / instances.size
|
val avgLoad = instances.map(_.subscriptionCount).sum.toDouble / instances.size
|
||||||
val maxCapacity = config.maxGamesPerCore * instances.size
|
val maxCapacity = config.maxGamesPerCore * instances.size
|
||||||
|
|
||||||
if avgLoad > config.scaleUpThreshold * config.maxGamesPerCore then
|
if avgLoad > config.scaleUpThreshold * config.maxGamesPerCore then
|
||||||
scaleUp()
|
scaleUp()
|
||||||
lastScaleTime = now
|
lastScaleTime = now
|
||||||
else if avgLoad < config.scaleDownThreshold * config.maxGamesPerCore && instances.size > config.scaleMinReplicas then
|
else if avgLoad < config.scaleDownThreshold * config.maxGamesPerCore && instances.size > config.scaleMinReplicas
|
||||||
|
then
|
||||||
scaleDown()
|
scaleDown()
|
||||||
lastScaleTime = now
|
lastScaleTime = now
|
||||||
|
|
||||||
private def scaleUp: Unit =
|
private def scaleUp(): Unit =
|
||||||
log.info("Scaling up Argo Rollout")
|
log.info("Scaling up Argo Rollout")
|
||||||
if kubeClient != null then
|
if kubeClient == null then
|
||||||
// Placeholder: will patch Rollout replicas
|
|
||||||
log.infof("Would scale up %s in namespace %s", config.k8sRolloutName, config.k8sNamespace)
|
|
||||||
else
|
|
||||||
log.warn("Kubernetes client not available, cannot scale")
|
log.warn("Kubernetes client not available, cannot scale")
|
||||||
|
return
|
||||||
|
|
||||||
private def scaleDown: Unit =
|
try
|
||||||
|
val rollout = kubeClient
|
||||||
|
.resources(classOf[io.fabric8.kubernetes.api.model.GenericKubernetesResource])
|
||||||
|
.inNamespace(config.k8sNamespace)
|
||||||
|
.withName(config.k8sRolloutName)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if rollout != null then
|
||||||
|
val spec = rollout.get("spec").asInstanceOf[java.util.Map[String, Any]]
|
||||||
|
val currentReplicas = spec.get("replicas").asInstanceOf[Integer].intValue()
|
||||||
|
val maxReplicas = config.scaleMaxReplicas
|
||||||
|
|
||||||
|
if currentReplicas < maxReplicas then
|
||||||
|
spec.put("replicas", currentReplicas + 1)
|
||||||
|
kubeClient
|
||||||
|
.resources(classOf[io.fabric8.kubernetes.api.model.GenericKubernetesResource])
|
||||||
|
.inNamespace(config.k8sNamespace)
|
||||||
|
.withName(config.k8sRolloutName)
|
||||||
|
.createOrReplace(rollout)
|
||||||
|
log.infof("Scaled up %s from %d to %d replicas", config.k8sRolloutName, currentReplicas, currentReplicas + 1)
|
||||||
|
else log.infof("Already at max replicas %d for %s", maxReplicas, config.k8sRolloutName)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to scale up %s", config.k8sRolloutName)
|
||||||
|
|
||||||
|
private def scaleDown(): Unit =
|
||||||
log.info("Scaling down Argo Rollout")
|
log.info("Scaling down Argo Rollout")
|
||||||
if kubeClient != null then
|
if kubeClient == null then
|
||||||
// Placeholder: will patch Rollout replicas
|
|
||||||
log.infof("Would scale down %s in namespace %s", config.k8sRolloutName, config.k8sNamespace)
|
|
||||||
else
|
|
||||||
log.warn("Kubernetes client not available, cannot scale")
|
log.warn("Kubernetes client not available, cannot scale")
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
val rollout = kubeClient
|
||||||
|
.resources(classOf[io.fabric8.kubernetes.api.model.GenericKubernetesResource])
|
||||||
|
.inNamespace(config.k8sNamespace)
|
||||||
|
.withName(config.k8sRolloutName)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if rollout != null then
|
||||||
|
val spec = rollout.get("spec").asInstanceOf[java.util.Map[String, Any]]
|
||||||
|
val currentReplicas = spec.get("replicas").asInstanceOf[Integer].intValue()
|
||||||
|
val minReplicas = config.scaleMinReplicas
|
||||||
|
|
||||||
|
if currentReplicas > minReplicas then
|
||||||
|
spec.put("replicas", currentReplicas - 1)
|
||||||
|
kubeClient
|
||||||
|
.resources(classOf[io.fabric8.kubernetes.api.model.GenericKubernetesResource])
|
||||||
|
.inNamespace(config.k8sNamespace)
|
||||||
|
.withName(config.k8sRolloutName)
|
||||||
|
.createOrReplace(rollout)
|
||||||
|
log.infof(
|
||||||
|
"Scaled down %s from %d to %d replicas",
|
||||||
|
config.k8sRolloutName,
|
||||||
|
currentReplicas,
|
||||||
|
currentReplicas - 1,
|
||||||
|
)
|
||||||
|
else log.infof("Already at min replicas %d for %s", minReplicas, config.k8sRolloutName)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to scale down %s", config.k8sRolloutName)
|
||||||
|
|||||||
+51
-12
@@ -4,22 +4,32 @@ import jakarta.enterprise.context.ApplicationScoped
|
|||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import org.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.util.Try
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class CacheEvictionManager:
|
class CacheEvictionManager:
|
||||||
@Inject
|
@Inject
|
||||||
private var redissonClient: RedissonClient = _
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var config: CoordinatorConfig = _
|
private var config: CoordinatorConfig = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var instanceRegistry: InstanceRegistry = _
|
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[CacheEvictionManager])
|
@Inject
|
||||||
|
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private var objectMapper: ObjectMapper = uninitialized
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[CacheEvictionManager])
|
||||||
private var redisPrefix = "nowchess"
|
private var redisPrefix = "nowchess"
|
||||||
|
|
||||||
def setRedisPrefix(prefix: String): Unit =
|
def setRedisPrefix(prefix: String): Unit =
|
||||||
@@ -29,25 +39,54 @@ class CacheEvictionManager:
|
|||||||
log.info("Starting cache eviction scan")
|
log.info("Starting cache eviction scan")
|
||||||
|
|
||||||
val pattern = s"$redisPrefix:game:entry:*"
|
val pattern = s"$redisPrefix:game:entry:*"
|
||||||
val keys = redissonClient.getKeys.getKeysByPattern(pattern, 100)
|
val keys = redissonClient.getKeys.getKeysByPattern(pattern, 100)
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val idleThresholdMs = config.gameIdleThreshold.toMillis
|
val idleThresholdMs = config.gameIdleThreshold.toMillis
|
||||||
|
|
||||||
var evictedCount = 0
|
var evictedCount = 0
|
||||||
keys.asScala.foreach { key =>
|
keys.asScala.foreach { key =>
|
||||||
try
|
try
|
||||||
val bucket = redissonClient.getBucket[String](key)
|
val bucket = redissonClient.getBucket[String](key)
|
||||||
val value = bucket.get()
|
val value = bucket.get()
|
||||||
if value != null then
|
if value != null then
|
||||||
// Parse JSON to extract lastUpdated timestamp
|
val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
|
||||||
// Placeholder: actual parsing will be implemented
|
val lastUpdated = extractLastUpdatedTimestamp(value)
|
||||||
val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
|
|
||||||
// Check if game should be evicted and call core
|
if lastUpdated > 0 && (now - lastUpdated) > idleThresholdMs then
|
||||||
()
|
findInstanceWithGame(gameId).foreach { instance =>
|
||||||
|
try
|
||||||
|
coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId))
|
||||||
|
bucket.delete()
|
||||||
|
evictedCount += 1
|
||||||
|
log.infof("Evicted idle game %s from %s", gameId, instance.instanceId)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to evict game %s", gameId)
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
case ex: Exception =>
|
case ex: Exception =>
|
||||||
log.warnf(ex, "Error processing game key %s", key)
|
log.warnf(ex, "Error processing game key %s", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.infof("Cache eviction scan completed, evicted %d games", evictedCount)
|
log.infof("Cache eviction scan completed, evicted %d games", evictedCount)
|
||||||
|
|
||||||
|
private def extractLastUpdatedTimestamp(json: String): Long =
|
||||||
|
Try {
|
||||||
|
val parsed = objectMapper.readTree(json)
|
||||||
|
val lastHeartbeat = parsed.get("lastHeartbeat")
|
||||||
|
if lastHeartbeat != null && lastHeartbeat.isTextual then Instant.parse(lastHeartbeat.asText()).toEpochMilli
|
||||||
|
else 0L
|
||||||
|
}.getOrElse(0L)
|
||||||
|
|
||||||
|
private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] =
|
||||||
|
try
|
||||||
|
instanceRegistry.getAllInstances.find { instance =>
|
||||||
|
val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
|
||||||
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
|
gameSet.contains(gameId)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.debugf(ex, "Failed to find instance for game %s", gameId)
|
||||||
|
None
|
||||||
|
|||||||
+33
-13
@@ -5,18 +5,24 @@ import jakarta.inject.Inject
|
|||||||
import org.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import scala.concurrent.duration.*
|
import scala.concurrent.duration.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||||
|
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class FailoverService:
|
class FailoverService:
|
||||||
@Inject
|
@Inject
|
||||||
private var redissonClient: RedissonClient = _
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var instanceRegistry: InstanceRegistry = _
|
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[FailoverService])
|
@Inject
|
||||||
|
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[FailoverService])
|
||||||
private var redisPrefix = "nowchess"
|
private var redisPrefix = "nowchess"
|
||||||
|
|
||||||
def setRedisPrefix(prefix: String): Unit =
|
def setRedisPrefix(prefix: String): Unit =
|
||||||
@@ -41,27 +47,41 @@ class FailoverService:
|
|||||||
|
|
||||||
val elapsed = System.currentTimeMillis() - startTime
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
log.infof("Failover completed in %dms for instance %s", elapsed, instanceId)
|
log.infof("Failover completed in %dms for instance %s", elapsed, instanceId)
|
||||||
else
|
else log.warnf("No healthy instances available for failover of %s", instanceId)
|
||||||
log.warnf("No healthy instances available for failover of %s", instanceId)
|
|
||||||
|
|
||||||
cleanupDeadInstance(instanceId)
|
cleanupDeadInstance(instanceId)
|
||||||
|
|
||||||
private def getOrphanedGames(instanceId: String): List[String] =
|
private def getOrphanedGames(instanceId: String): List[String] =
|
||||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||||
val gameSet = redissonClient.getSet[String](setKey)
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
gameSet.readAll.asScala.toList
|
gameSet.readAll.asScala.toList
|
||||||
|
|
||||||
private def distributeGames(
|
private def distributeGames(
|
||||||
gameIds: List[String],
|
gameIds: List[String],
|
||||||
healthyInstances: List[_],
|
healthyInstances: List[InstanceMetadata],
|
||||||
deadInstanceId: String
|
deadInstanceId: String,
|
||||||
): Unit =
|
): Unit =
|
||||||
// Placeholder: will be replaced with actual gRPC calls
|
if gameIds.isEmpty || healthyInstances.isEmpty then return
|
||||||
log.infof("Would distribute %d games from %s to %d healthy instances",
|
|
||||||
gameIds.size, deadInstanceId, healthyInstances.size)
|
val batchSize = math.max(1, gameIds.size / healthyInstances.size)
|
||||||
|
val batches = gameIds.grouped(batchSize).toList
|
||||||
|
|
||||||
|
batches.zipWithIndex.foreach { case (batch, idx) =>
|
||||||
|
val targetInstance = healthyInstances(idx % healthyInstances.size)
|
||||||
|
try
|
||||||
|
val subscribed = coreGrpcClient.batchResubscribeGames(
|
||||||
|
targetInstance.hostname,
|
||||||
|
targetInstance.grpcPort,
|
||||||
|
batch,
|
||||||
|
)
|
||||||
|
log.infof("Migrated %d games from %s to %s", subscribed, deadInstanceId, targetInstance.instanceId)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to migrate batch to %s, will retry", targetInstance.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
private def cleanupDeadInstance(instanceId: String): Unit =
|
private def cleanupDeadInstance(instanceId: String): Unit =
|
||||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||||
val gameSet = redissonClient.getSet[String](setKey)
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
gameSet.delete()
|
gameSet.delete()
|
||||||
log.infof("Cleaned up games set for instance %s", instanceId)
|
log.infof("Cleaned up games set for instance %s", instanceId)
|
||||||
|
|||||||
+71
-15
@@ -5,22 +5,31 @@ import jakarta.inject.Inject
|
|||||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||||
import io.fabric8.kubernetes.client.KubernetesClient
|
import io.fabric8.kubernetes.client.KubernetesClient
|
||||||
import io.fabric8.kubernetes.api.model.Pod
|
import io.fabric8.kubernetes.api.model.Pod
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class HealthMonitor:
|
class HealthMonitor:
|
||||||
@Inject(optional = true)
|
@Inject
|
||||||
private var kubeClient: KubernetesClient = _
|
private var kubeClient: KubernetesClient = null
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var config: CoordinatorConfig = _
|
private var config: CoordinatorConfig = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var instanceRegistry: InstanceRegistry = _
|
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[HealthMonitor])
|
@Inject
|
||||||
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[HealthMonitor])
|
||||||
|
private var redisPrefix = "nowchess"
|
||||||
|
|
||||||
|
def setRedisPrefix(prefix: String): Unit =
|
||||||
|
redisPrefix = prefix
|
||||||
|
|
||||||
def checkInstanceHealth: Unit =
|
def checkInstanceHealth: Unit =
|
||||||
val instances = instanceRegistry.getAllInstances
|
val instances = instanceRegistry.getAllInstances
|
||||||
@@ -32,29 +41,76 @@ class HealthMonitor:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private def checkHealth(instanceId: String): Boolean =
|
private def checkHealth(instanceId: String): Boolean =
|
||||||
// Placeholder: will check Redis heartbeat, k8s pod status, and HTTP health endpoint
|
val redisHealthy = checkRedisHeartbeat(instanceId)
|
||||||
true
|
val k8sHealthy = checkK8sPodStatus(instanceId)
|
||||||
|
redisHealthy && k8sHealthy
|
||||||
|
|
||||||
def watchK8sPods: Unit =
|
private def checkRedisHeartbeat(instanceId: String): Boolean =
|
||||||
if kubeClient != null then
|
try
|
||||||
|
val key = s"$redisPrefix:instances:$instanceId"
|
||||||
|
val bucket = redissonClient.getBucket[String](key)
|
||||||
|
val ttl = bucket.remainTimeToLive()
|
||||||
|
ttl > 0
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.debugf(ex, "Redis heartbeat check failed for %s", instanceId)
|
||||||
|
false
|
||||||
|
|
||||||
|
private def checkK8sPodStatus(instanceId: String): Boolean =
|
||||||
|
if kubeClient == null then true
|
||||||
|
else
|
||||||
try
|
try
|
||||||
val pods = kubeClient.pods()
|
val pods = kubeClient
|
||||||
|
.pods()
|
||||||
.inNamespace(config.k8sNamespace)
|
.inNamespace(config.k8sNamespace)
|
||||||
.withLabel(config.k8sRolloutLabelSelector)
|
.withLabel(config.k8sRolloutLabelSelector)
|
||||||
.list()
|
.list()
|
||||||
.getItems
|
.getItems
|
||||||
.asScala
|
.asScala
|
||||||
|
|
||||||
pods.foreach { pod =>
|
pods.exists { pod =>
|
||||||
val podName = pod.getMetadata.getName
|
val podName = pod.getMetadata.getName
|
||||||
val isReady = isPodReady(pod)
|
podName.contains(instanceId) && isPodReady(pod)
|
||||||
log.debugf("Pod %s ready: %b", podName, isReady)
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
case ex: Exception =>
|
case ex: Exception =>
|
||||||
log.warnf(ex, "Failed to watch k8s pods")
|
log.debugf(ex, "K8s pod status check failed for %s", instanceId)
|
||||||
else
|
true
|
||||||
|
|
||||||
|
def watchK8sPods: Unit =
|
||||||
|
if kubeClient == null then
|
||||||
log.debug("Kubernetes client not available for pod watch")
|
log.debug("Kubernetes client not available for pod watch")
|
||||||
|
return
|
||||||
|
|
||||||
|
try
|
||||||
|
val pods = kubeClient
|
||||||
|
.pods()
|
||||||
|
.inNamespace(config.k8sNamespace)
|
||||||
|
.withLabel(config.k8sRolloutLabelSelector)
|
||||||
|
.list()
|
||||||
|
.getItems
|
||||||
|
.asScala
|
||||||
|
|
||||||
|
val instances = instanceRegistry.getAllInstances
|
||||||
|
instances.foreach { inst =>
|
||||||
|
val matchingPod = pods.find { pod =>
|
||||||
|
pod.getMetadata.getName.contains(inst.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingPod match
|
||||||
|
case Some(pod) =>
|
||||||
|
val isReady = isPodReady(pod)
|
||||||
|
if !isReady && inst.state == "HEALTHY" then
|
||||||
|
log.warnf("Pod %s not ready, marking instance %s dead", pod.getMetadata.getName, inst.instanceId)
|
||||||
|
instanceRegistry.markInstanceDead(inst.instanceId)
|
||||||
|
case None =>
|
||||||
|
if inst.state == "HEALTHY" then
|
||||||
|
log.warnf("No pod found for instance %s, marking dead", inst.instanceId)
|
||||||
|
instanceRegistry.markInstanceDead(inst.instanceId)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to watch k8s pods")
|
||||||
|
|
||||||
private def isPodReady(pod: Pod): Boolean =
|
private def isPodReady(pod: Pod): Boolean =
|
||||||
val status = pod.getStatus
|
val status = pod.getStatus
|
||||||
|
|||||||
+12
-15
@@ -5,6 +5,7 @@ import jakarta.inject.Inject
|
|||||||
import org.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -12,10 +13,10 @@ import java.time.Instant
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class InstanceRegistry:
|
class InstanceRegistry:
|
||||||
@Inject
|
@Inject
|
||||||
private var redissonClient: RedissonClient = _
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
private val mapper = ObjectMapper()
|
private val mapper = ObjectMapper()
|
||||||
private val instances = mutable.Map[String, InstanceMetadata]()
|
private val instances = mutable.Map[String, InstanceMetadata]()
|
||||||
private var redisPrefix = "nowchess"
|
private var redisPrefix = "nowchess"
|
||||||
|
|
||||||
def setRedisPrefix(prefix: String): Unit =
|
def setRedisPrefix(prefix: String): Unit =
|
||||||
@@ -29,29 +30,25 @@ class InstanceRegistry:
|
|||||||
|
|
||||||
def listInstancesFromRedis: List[InstanceMetadata] =
|
def listInstancesFromRedis: List[InstanceMetadata] =
|
||||||
val pattern = s"$redisPrefix:instances:*"
|
val pattern = s"$redisPrefix:instances:*"
|
||||||
val keys = redissonClient.getKeys.getKeysByPattern(pattern, 100)
|
val keys = redissonClient.getKeys.getKeysByPattern(pattern, 100)
|
||||||
keys.asScala.flatMap { key =>
|
keys.asScala.flatMap { key =>
|
||||||
val bucket = redissonClient.getBucket[String](key)
|
val bucket = redissonClient.getBucket[String](key)
|
||||||
val value = bucket.getAndDelete()
|
val value = bucket.getAndDelete()
|
||||||
if value != null then
|
if value != null then
|
||||||
try
|
try Some(mapper.readValue(value, classOf[InstanceMetadata]))
|
||||||
Some(mapper.readValue(value, classOf[InstanceMetadata]))
|
catch case _: Exception => None
|
||||||
catch
|
else None
|
||||||
case _: Exception => None
|
|
||||||
else
|
|
||||||
None
|
|
||||||
}.toList
|
}.toList
|
||||||
|
|
||||||
def updateInstanceFromRedis(instanceId: String): Unit =
|
def updateInstanceFromRedis(instanceId: String): Unit =
|
||||||
val key = s"$redisPrefix:instances:$instanceId"
|
val key = s"$redisPrefix:instances:$instanceId"
|
||||||
val bucket = redissonClient.getBucket[String](key)
|
val bucket = redissonClient.getBucket[String](key)
|
||||||
val value = bucket.get()
|
val value = bucket.get()
|
||||||
if value != null then
|
if value != null then
|
||||||
try
|
try
|
||||||
val metadata = mapper.readValue(value, classOf[InstanceMetadata])
|
val metadata = mapper.readValue(value, classOf[InstanceMetadata])
|
||||||
instances(instanceId) = metadata
|
instances(instanceId) = metadata
|
||||||
catch
|
catch case _: Exception => ()
|
||||||
case _: Exception => ()
|
|
||||||
|
|
||||||
def markInstanceDead(instanceId: String): Unit =
|
def markInstanceDead(instanceId: String): Unit =
|
||||||
instances.get(instanceId).foreach { inst =>
|
instances.get(instanceId).foreach { inst =>
|
||||||
|
|||||||
+95
-12
@@ -3,42 +3,125 @@ package de.nowchess.coordinator.service
|
|||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
import scala.concurrent.duration.*
|
import scala.concurrent.duration.*
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
|
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class LoadBalancer:
|
class LoadBalancer:
|
||||||
@Inject
|
@Inject
|
||||||
private var config: CoordinatorConfig = _
|
private var config: CoordinatorConfig = uninitialized
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private var instanceRegistry: InstanceRegistry = _
|
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[LoadBalancer])
|
@Inject
|
||||||
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[LoadBalancer])
|
||||||
private var lastRebalanceTime = 0L
|
private var lastRebalanceTime = 0L
|
||||||
|
private var redisPrefix = "nowchess"
|
||||||
|
|
||||||
|
def setRedisPrefix(prefix: String): Unit =
|
||||||
|
redisPrefix = prefix
|
||||||
|
|
||||||
def shouldRebalance: Boolean =
|
def shouldRebalance: Boolean =
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val minInterval = config.rebalanceMinInterval.toMillis
|
val minInterval = config.rebalanceMinInterval.toMillis
|
||||||
if now - lastRebalanceTime < minInterval then
|
if now - lastRebalanceTime < minInterval then return false
|
||||||
return false
|
|
||||||
|
|
||||||
val instances = instanceRegistry.getAllInstances
|
val instances = instanceRegistry.getAllInstances
|
||||||
if instances.isEmpty then return false
|
if instances.isEmpty then return false
|
||||||
|
|
||||||
val loads = instances.map(_.subscriptionCount)
|
val loads = instances.map(_.subscriptionCount)
|
||||||
val maxLoad = loads.max
|
val maxLoad = loads.max
|
||||||
val minLoad = loads.min
|
val minLoad = loads.min
|
||||||
val avgLoad = loads.sum.toDouble / loads.size
|
val avgLoad = loads.sum.toDouble / loads.size
|
||||||
|
|
||||||
val exceededMax = maxLoad > config.maxGamesPerCore
|
val exceededMax = maxLoad > config.maxGamesPerCore
|
||||||
val deviationPercent = 100.0 * (maxLoad - avgLoad) / avgLoad
|
val deviationPercent = 100.0 * (maxLoad - avgLoad) / avgLoad
|
||||||
val exceededDeviation = maxLoad > avgLoad && deviationPercent > config.maxDeviationPercent && (maxLoad - minLoad) > 50
|
val exceededDeviation =
|
||||||
|
maxLoad > avgLoad && deviationPercent > config.maxDeviationPercent && (maxLoad - minLoad) > 50
|
||||||
|
|
||||||
exceededMax || exceededDeviation
|
exceededMax || exceededDeviation
|
||||||
|
|
||||||
def rebalance: Unit =
|
def rebalance: Unit =
|
||||||
log.info("Starting rebalance")
|
log.info("Starting rebalance")
|
||||||
lastRebalanceTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
// Placeholder: actual rebalance logic will be implemented
|
lastRebalanceTime = startTime
|
||||||
log.info("Rebalance completed")
|
|
||||||
|
try
|
||||||
|
val instances = instanceRegistry.getAllInstances
|
||||||
|
.filter(_.state == "HEALTHY")
|
||||||
|
|
||||||
|
if instances.size < 2 then
|
||||||
|
log.info("Not enough healthy instances for rebalance")
|
||||||
|
return
|
||||||
|
|
||||||
|
val loads = instances.map(_.subscriptionCount)
|
||||||
|
val avgLoad = loads.sum.toDouble / loads.size
|
||||||
|
|
||||||
|
val overloaded = instances
|
||||||
|
.filter(_.subscriptionCount > config.maxGamesPerCore)
|
||||||
|
.sortBy[Int](_.subscriptionCount)
|
||||||
|
.reverse
|
||||||
|
val underloaded = instances
|
||||||
|
.filter(_.subscriptionCount < avgLoad * 0.8)
|
||||||
|
.sortBy(_.subscriptionCount)
|
||||||
|
|
||||||
|
overloaded.foreach { over =>
|
||||||
|
val excess = over.subscriptionCount - avgLoad.toInt
|
||||||
|
if excess > 0 && underloaded.nonEmpty then
|
||||||
|
val gamesToMove = getGamesToMove(over.instanceId, excess)
|
||||||
|
if gamesToMove.nonEmpty then
|
||||||
|
underloaded.headOption.foreach { under =>
|
||||||
|
try
|
||||||
|
val unsubscribed = coreGrpcClient.unsubscribeGames(over.hostname, over.grpcPort, gamesToMove)
|
||||||
|
val subscribed = coreGrpcClient.batchResubscribeGames(under.hostname, under.grpcPort, gamesToMove)
|
||||||
|
|
||||||
|
if subscribed > 0 then
|
||||||
|
updateRedisGameSets(over.instanceId, under.instanceId, gamesToMove)
|
||||||
|
log.infof("Moved %d games from %s to %s", subscribed, over.instanceId, under.instanceId)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to move games from %s to %s", over.instanceId, under.instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
|
log.infof("Rebalance completed in %dms", elapsed)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Rebalance failed")
|
||||||
|
|
||||||
|
private def getGamesToMove(instanceId: String, count: Int): List[String] =
|
||||||
|
try
|
||||||
|
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||||
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
|
gameSet.readAll.asScala.toList.take(count)
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.debugf(ex, "Failed to get games for %s", instanceId)
|
||||||
|
List()
|
||||||
|
|
||||||
|
private def updateRedisGameSets(fromInstanceId: String, toInstanceId: String, gameIds: List[String]): Unit =
|
||||||
|
try
|
||||||
|
val fromKey = s"$redisPrefix:instance:$fromInstanceId:games"
|
||||||
|
val toKey = s"$redisPrefix:instance:$toInstanceId:games"
|
||||||
|
|
||||||
|
val fromSet = redissonClient.getSet[String](fromKey)
|
||||||
|
val toSet = redissonClient.getSet[String](toKey)
|
||||||
|
|
||||||
|
gameIds.foreach { gameId =>
|
||||||
|
fromSet.remove(gameId)
|
||||||
|
toSet.add(gameId)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
case ex: Exception =>
|
||||||
|
log.warnf(ex, "Failed to update Redis game sets")
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
|||||||
if (name == "compileScoverageScala") {
|
if (name == "compileScoverageScala") {
|
||||||
source = source.asFileTree.matching {
|
source = source.asFileTree.matching {
|
||||||
exclude("**/grpc/*.scala")
|
exclude("**/grpc/*.scala")
|
||||||
|
exclude("**/coordinator/*.scala")
|
||||||
|
exclude("**/registry/RedisGameRegistry.scala")
|
||||||
|
exclude("**/service/InstanceHeartbeatService.scala")
|
||||||
exclude("**/resource/GameDtoMapper.scala")
|
exclude("**/resource/GameDtoMapper.scala")
|
||||||
exclude("**/resource/GameResource.scala")
|
exclude("**/resource/GameResource.scala")
|
||||||
exclude("**/redis/GameRedis*.scala")
|
exclude("**/redis/GameRedis*.scala")
|
||||||
@@ -139,3 +142,7 @@ tasks.named("compileScoverageJava").configure {
|
|||||||
dependsOn(tasks.named("quarkusGenerateCode"))
|
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.compileScala {
|
||||||
|
dependsOn(tasks.named("compileJava"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
option java_package = "de.nowchess.coordinator.proto";
|
||||||
package de.nowchess.coordinator;
|
option java_multiple_files = true;
|
||||||
|
option java_outer_classname = "CoordinatorServiceProto";
|
||||||
|
|
||||||
service CoordinatorService {
|
service CoordinatorService {
|
||||||
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
package de.nowchess.chess.client
|
package de.nowchess.chess.client
|
||||||
|
|
||||||
case class GameRecordDto(
|
case class GameRecordDto(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
pgn: String,
|
pgn: String,
|
||||||
moveCount: Int,
|
moveCount: Int,
|
||||||
whiteId: String,
|
whiteId: String,
|
||||||
whiteName: String,
|
whiteName: String,
|
||||||
blackId: String,
|
blackId: String,
|
||||||
blackName: String,
|
blackName: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
resigned: Boolean,
|
resigned: Boolean,
|
||||||
limitSeconds: java.lang.Integer,
|
limitSeconds: java.lang.Integer,
|
||||||
incrementSeconds: java.lang.Integer,
|
incrementSeconds: java.lang.Integer,
|
||||||
daysPerMove: java.lang.Integer,
|
daysPerMove: java.lang.Integer,
|
||||||
whiteRemainingMs: java.lang.Long,
|
whiteRemainingMs: java.lang.Long,
|
||||||
blackRemainingMs: java.lang.Long,
|
blackRemainingMs: java.lang.Long,
|
||||||
incrementMs: java.lang.Long,
|
incrementMs: java.lang.Long,
|
||||||
clockLastTickAt: java.lang.Long,
|
clockLastTickAt: java.lang.Long,
|
||||||
clockMoveDeadline: java.lang.Long,
|
clockMoveDeadline: java.lang.Long,
|
||||||
clockActiveColor: String,
|
clockActiveColor: String,
|
||||||
pendingDrawOffer: String,
|
pendingDrawOffer: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ class GameEngine(
|
|||||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||||
val captured = computeCaptured(currentContext, move)
|
val captured = computeCaptured(currentContext, move)
|
||||||
val notation = translateMoveToNotation(move, contextBefore.board)
|
val notation = translateMoveToNotation(move, contextBefore.board)
|
||||||
currentContext = nextContext
|
currentContext = nextContext
|
||||||
|
|
||||||
advanceClock(contextBefore.turn)
|
advanceClock(contextBefore.turn)
|
||||||
|
|
||||||
@@ -533,8 +533,7 @@ class GameEngine(
|
|||||||
notifyObservers(MoveUndoneEvent(currentContext, notation))
|
notifyObservers(MoveUndoneEvent(currentContext, notation))
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if redoStack.isEmpty then
|
if redoStack.isEmpty then notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
|
||||||
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
|
|
||||||
else
|
else
|
||||||
val move = redoStack.head
|
val move = redoStack.head
|
||||||
redoStack = redoStack.tail
|
redoStack = redoStack.tail
|
||||||
|
|||||||
+23
-26
@@ -1,66 +1,63 @@
|
|||||||
package de.nowchess.chess.grpc
|
package de.nowchess.chess.grpc
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import de.nowchess.coordinator.CoordinatorServiceGrpc
|
import jakarta.inject.Singleton
|
||||||
import de.nowchess.coordinator.{
|
import io.quarkus.grpc.GrpcService
|
||||||
BatchResubscribeRequest,
|
import scala.compiletime.uninitialized
|
||||||
BatchResubscribeResponse,
|
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||||
UnsubscribeGamesRequest,
|
|
||||||
UnsubscribeGamesResponse,
|
|
||||||
EvictGamesRequest,
|
|
||||||
EvictGamesResponse,
|
|
||||||
DrainInstanceRequest,
|
|
||||||
DrainInstanceResponse
|
|
||||||
}
|
|
||||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
import io.grpc.stub.StreamObserver
|
import io.grpc.stub.StreamObserver
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
|
|
||||||
@ApplicationScoped
|
@GrpcService
|
||||||
|
@Singleton
|
||||||
class CoordinatorServiceHandler extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
|
class CoordinatorServiceHandler extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
|
||||||
@Inject
|
@Inject
|
||||||
private var gameSubscriberManager: GameRedisSubscriberManager = _
|
private var gameSubscriberManager: GameRedisSubscriberManager = uninitialized
|
||||||
|
|
||||||
override def batchResubscribeGames(
|
override def batchResubscribeGames(
|
||||||
request: BatchResubscribeRequest,
|
request: BatchResubscribeRequest,
|
||||||
responseObserver: StreamObserver[BatchResubscribeResponse]
|
responseObserver: StreamObserver[BatchResubscribeResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
val count = gameSubscriberManager.batchResubscribeGames(request.getGameIdsList)
|
val count = gameSubscriberManager.batchResubscribeGames(request.getGameIdsList)
|
||||||
val response = BatchResubscribeResponse.newBuilder()
|
val response = BatchResubscribeResponse
|
||||||
|
.newBuilder()
|
||||||
.setSubscribedCount(count)
|
.setSubscribedCount(count)
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def unsubscribeGames(
|
override def unsubscribeGames(
|
||||||
request: UnsubscribeGamesRequest,
|
request: UnsubscribeGamesRequest,
|
||||||
responseObserver: StreamObserver[UnsubscribeGamesResponse]
|
responseObserver: StreamObserver[UnsubscribeGamesResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
val count = gameSubscriberManager.unsubscribeGames(request.getGameIdsList)
|
val count = gameSubscriberManager.unsubscribeGames(request.getGameIdsList)
|
||||||
val response = UnsubscribeGamesResponse.newBuilder()
|
val response = UnsubscribeGamesResponse
|
||||||
|
.newBuilder()
|
||||||
.setUnsubscribedCount(count)
|
.setUnsubscribedCount(count)
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def evictGames(
|
override def evictGames(
|
||||||
request: EvictGamesRequest,
|
request: EvictGamesRequest,
|
||||||
responseObserver: StreamObserver[EvictGamesResponse]
|
responseObserver: StreamObserver[EvictGamesResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
val count = gameSubscriberManager.evictGames(request.getGameIdsList)
|
val count = gameSubscriberManager.evictGames(request.getGameIdsList)
|
||||||
val response = EvictGamesResponse.newBuilder()
|
val response = EvictGamesResponse
|
||||||
|
.newBuilder()
|
||||||
.setEvictedCount(count)
|
.setEvictedCount(count)
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
responseObserver.onCompleted()
|
responseObserver.onCompleted()
|
||||||
|
|
||||||
override def drainInstance(
|
override def drainInstance(
|
||||||
request: DrainInstanceRequest,
|
request: DrainInstanceRequest,
|
||||||
responseObserver: StreamObserver[DrainInstanceResponse]
|
responseObserver: StreamObserver[DrainInstanceResponse],
|
||||||
): Unit =
|
): Unit =
|
||||||
gameSubscriberManager.drainInstance()
|
gameSubscriberManager.drainInstance()
|
||||||
val response = DrainInstanceResponse.newBuilder()
|
val response = DrainInstanceResponse
|
||||||
|
.newBuilder()
|
||||||
.setGamesMigrated(0)
|
.setGamesMigrated(0)
|
||||||
.build()
|
.build()
|
||||||
responseObserver.onNext(response)
|
responseObserver.onNext(response)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.grpc
|
package de.nowchess.chess.grpc
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights}
|
import de.nowchess.api.board.CastlingRights as DomainCastlingRights
|
||||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
||||||
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
||||||
import de.nowchess.core.proto.*
|
import de.nowchess.core.proto.*
|
||||||
@@ -58,7 +58,12 @@ object CoreProtoMapper:
|
|||||||
case _ => MoveType.Normal(false)
|
case _ => MoveType.Normal(false)
|
||||||
|
|
||||||
def toProtoMove(m: DomainMove): ProtoMove =
|
def toProtoMove(m: DomainMove): ProtoMove =
|
||||||
ProtoMove.newBuilder().setFrom(m.from.toString).setTo(m.to.toString).setMoveKind(toProtoMoveKind(m.moveType)).build()
|
ProtoMove
|
||||||
|
.newBuilder()
|
||||||
|
.setFrom(m.from.toString)
|
||||||
|
.setTo(m.to.toString)
|
||||||
|
.setMoveKind(toProtoMoveKind(m.moveType))
|
||||||
|
.build()
|
||||||
|
|
||||||
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
||||||
for
|
for
|
||||||
@@ -67,16 +72,33 @@ object CoreProtoMapper:
|
|||||||
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
||||||
|
|
||||||
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
||||||
board.pieces.map { (sq, piece) =>
|
board.pieces
|
||||||
ProtoSquarePiece
|
.map { (sq, piece) =>
|
||||||
.newBuilder()
|
ProtoSquarePiece
|
||||||
.setSquare(sq.toString)
|
.newBuilder()
|
||||||
.setPiece(ProtoPiece.newBuilder().setColor(toProtoColor(piece.color)).setPieceType(toProtoPieceType(piece.pieceType)).build())
|
.setSquare(sq.toString)
|
||||||
.build()
|
.setPiece(
|
||||||
}.toSeq.asJava
|
ProtoPiece
|
||||||
|
.newBuilder()
|
||||||
|
.setColor(toProtoColor(piece.color))
|
||||||
|
.setPieceType(toProtoPieceType(piece.pieceType))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.toSeq
|
||||||
|
.asJava
|
||||||
|
|
||||||
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
||||||
Board(pieces.asScala.flatMap(sp => Square.fromAlgebraic(sp.getSquare).map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))).toMap)
|
Board(
|
||||||
|
pieces.asScala
|
||||||
|
.flatMap(sp =>
|
||||||
|
Square
|
||||||
|
.fromAlgebraic(sp.getSquare)
|
||||||
|
.map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType))),
|
||||||
|
)
|
||||||
|
.toMap,
|
||||||
|
)
|
||||||
|
|
||||||
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
||||||
case None => ProtoGameResultKind.ONGOING
|
case None => ProtoGameResultKind.ONGOING
|
||||||
@@ -131,12 +153,13 @@ object CoreProtoMapper:
|
|||||||
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
||||||
val cr = p.getCastlingRights
|
val cr = p.getCastlingRights
|
||||||
GameContext(
|
GameContext(
|
||||||
board = fromProtoBoard(p.getBoardList),
|
board = fromProtoBoard(p.getBoardList),
|
||||||
turn = fromProtoColor(p.getTurn),
|
turn = fromProtoColor(p.getTurn),
|
||||||
castlingRights = DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
castlingRights =
|
||||||
|
DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
||||||
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
||||||
halfMoveClock = p.getHalfMoveClock,
|
halfMoveClock = p.getHalfMoveClock,
|
||||||
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
||||||
result = fromProtoResultKind(p.getResult),
|
result = fromProtoResultKind(p.getResult),
|
||||||
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,15 +20,22 @@ class RuleSetGrpcAdapter extends RuleSet:
|
|||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
|
def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
|
||||||
val req = ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
|
val req =
|
||||||
|
ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
|
||||||
stub.candidateMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
|
stub.candidateMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
|
||||||
|
|
||||||
def legalMoves(ctx: GameContext)(sq: Square): List[Move] =
|
def legalMoves(ctx: GameContext)(sq: Square): List[Move] =
|
||||||
val req = ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
|
val req =
|
||||||
|
ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
|
||||||
stub.legalMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
|
stub.legalMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
|
||||||
|
|
||||||
def allLegalMoves(ctx: GameContext): List[Move] =
|
def allLegalMoves(ctx: GameContext): List[Move] =
|
||||||
stub.allLegalMoves(CoreProtoMapper.toProtoGameContext(ctx)).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
|
stub
|
||||||
|
.allLegalMoves(CoreProtoMapper.toProtoGameContext(ctx))
|
||||||
|
.getMovesList
|
||||||
|
.asScala
|
||||||
|
.flatMap(CoreProtoMapper.fromProtoMove)
|
||||||
|
.toList
|
||||||
|
|
||||||
def isCheck(ctx: GameContext): Boolean =
|
def isCheck(ctx: GameContext): Boolean =
|
||||||
stub.isCheck(CoreProtoMapper.toProtoGameContext(ctx)).getValue
|
stub.isCheck(CoreProtoMapper.toProtoGameContext(ctx)).getValue
|
||||||
@@ -49,9 +56,19 @@ class RuleSetGrpcAdapter extends RuleSet:
|
|||||||
stub.isThreefoldRepetition(CoreProtoMapper.toProtoGameContext(ctx)).getValue
|
stub.isThreefoldRepetition(CoreProtoMapper.toProtoGameContext(ctx)).getValue
|
||||||
|
|
||||||
def applyMove(ctx: GameContext)(move: Move): GameContext =
|
def applyMove(ctx: GameContext)(move: Move): GameContext =
|
||||||
val req = ProtoMoveRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setMove(CoreProtoMapper.toProtoMove(move)).build()
|
val req = ProtoMoveRequest
|
||||||
|
.newBuilder()
|
||||||
|
.setContext(CoreProtoMapper.toProtoGameContext(ctx))
|
||||||
|
.setMove(CoreProtoMapper.toProtoMove(move))
|
||||||
|
.build()
|
||||||
CoreProtoMapper.fromProtoGameContext(stub.applyMove(req))
|
CoreProtoMapper.fromProtoGameContext(stub.applyMove(req))
|
||||||
|
|
||||||
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
|
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
|
||||||
val p = stub.postMoveStatus(CoreProtoMapper.toProtoGameContext(ctx))
|
val p = stub.postMoveStatus(CoreProtoMapper.toProtoGameContext(ctx))
|
||||||
PostMoveStatus(p.getIsCheckmate, p.getIsStalemate, p.getIsInsufficientMaterial, p.getIsCheck, p.getIsThreefoldRepetition)
|
PostMoveStatus(
|
||||||
|
p.getIsCheckmate,
|
||||||
|
p.getIsStalemate,
|
||||||
|
p.getIsInsufficientMaterial,
|
||||||
|
p.getIsCheck,
|
||||||
|
p.getIsThreefoldRepetition,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ package de.nowchess.chess.redis
|
|||||||
sealed trait C2sMessage
|
sealed trait C2sMessage
|
||||||
|
|
||||||
object C2sMessage:
|
object C2sMessage:
|
||||||
case object Connected extends C2sMessage
|
case object Connected extends C2sMessage
|
||||||
case class Move(uci: String) extends C2sMessage
|
case class Move(uci: String) extends C2sMessage
|
||||||
case object Ping extends C2sMessage
|
case object Ping extends C2sMessage
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class GameRedisPublisher(
|
|||||||
|
|
||||||
def onGameEvent(event: GameEvent): Unit =
|
def onGameEvent(event: GameEvent): Unit =
|
||||||
registry.get(gameId).foreach { entry =>
|
registry.get(gameId).foreach { entry =>
|
||||||
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
|
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
|
||||||
val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
|
val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
|
||||||
redisson.getTopic(s2cTopicName).publish(json)
|
redisson.getTopic(s2cTopicName).publish(json)
|
||||||
|
|
||||||
@@ -38,9 +38,15 @@ class GameRedisPublisher(
|
|||||||
blackName = entry.black.displayName,
|
blackName = entry.black.displayName,
|
||||||
mode = entry.mode.toString,
|
mode = entry.mode.toString,
|
||||||
resigned = entry.resigned,
|
resigned = entry.resigned,
|
||||||
limitSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(l, _) => Some(l); case _ => None },
|
limitSeconds = entry.engine.timeControl match {
|
||||||
incrementSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(_, i) => Some(i); case _ => None },
|
case de.nowchess.api.game.TimeControl.Clock(l, _) => Some(l); case _ => None
|
||||||
daysPerMove = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Correspondence(d) => Some(d); case _ => None },
|
},
|
||||||
|
incrementSeconds = entry.engine.timeControl match {
|
||||||
|
case de.nowchess.api.game.TimeControl.Clock(_, i) => Some(i); case _ => None
|
||||||
|
},
|
||||||
|
daysPerMove = entry.engine.timeControl match {
|
||||||
|
case de.nowchess.api.game.TimeControl.Correspondence(d) => Some(d); case _ => None
|
||||||
|
},
|
||||||
whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
|
whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
|
||||||
blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
|
blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
|
||||||
incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
|
incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
|
||||||
|
|||||||
+31
-27
@@ -21,12 +21,12 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
class GameRedisSubscriberManager:
|
class GameRedisSubscriberManager:
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject var redisson: RedissonClient = uninitialized
|
@Inject var redisson: RedissonClient = uninitialized
|
||||||
@Inject var registry: GameRegistry = uninitialized
|
@Inject var registry: GameRegistry = uninitialized
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var redisConfig: RedisConfig = uninitialized
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
||||||
@Inject(optional = true) var heartbeatService: InstanceHeartbeatService = uninitialized
|
@Inject var heartbeatService: InstanceHeartbeatService = null
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val c2sListeners = new ConcurrentHashMap[String, Int]()
|
private val c2sListeners = new ConcurrentHashMap[String, Int]()
|
||||||
@@ -41,20 +41,30 @@ class GameRedisSubscriberManager:
|
|||||||
def subscribeGame(gameId: String): Unit =
|
def subscribeGame(gameId: String): Unit =
|
||||||
try
|
try
|
||||||
val topic = redisson.getTopic(c2sTopic(gameId))
|
val topic = redisson.getTopic(c2sTopic(gameId))
|
||||||
val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
|
val listenerId = topic.addListener(
|
||||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
classOf[String],
|
||||||
handleC2sMessage(gameId, msg)
|
new MessageListener[String]:
|
||||||
|
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||||
|
handleC2sMessage(gameId, msg),
|
||||||
)
|
)
|
||||||
c2sListeners.put(gameId, listenerId)
|
c2sListeners.put(gameId, listenerId)
|
||||||
|
|
||||||
val writebackTopic = redisson.getTopic("game-writeback")
|
val writebackTopic = redisson.getTopic("game-writeback")
|
||||||
val writebackFn: String => Unit = json => writebackTopic.publish(json)
|
val writebackFn: String => Unit = json => writebackTopic.publish(json)
|
||||||
val obs = new GameRedisPublisher(gameId, registry, redisson, objectMapper, s2cTopicName(gameId), writebackFn, ioClient, unsubscribeGame)
|
val obs = new GameRedisPublisher(
|
||||||
|
gameId,
|
||||||
|
registry,
|
||||||
|
redisson,
|
||||||
|
objectMapper,
|
||||||
|
s2cTopicName(gameId),
|
||||||
|
writebackFn,
|
||||||
|
ioClient,
|
||||||
|
unsubscribeGame,
|
||||||
|
)
|
||||||
s2cObservers.put(gameId, obs)
|
s2cObservers.put(gameId, obs)
|
||||||
registry.get(gameId).foreach(_.engine.subscribe(obs))
|
registry.get(gameId).foreach(_.engine.subscribe(obs))
|
||||||
|
|
||||||
if heartbeatService != null then
|
if heartbeatService != null then heartbeatService.addGameSubscription(gameId)
|
||||||
heartbeatService.addGameSubscription(gameId)
|
|
||||||
catch
|
catch
|
||||||
case e: Exception =>
|
case e: Exception =>
|
||||||
System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
|
System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
|
||||||
@@ -68,19 +78,18 @@ class GameRedisSubscriberManager:
|
|||||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||||
}
|
}
|
||||||
|
|
||||||
if heartbeatService != null then
|
if heartbeatService != null then heartbeatService.removeGameSubscription(gameId)
|
||||||
heartbeatService.removeGameSubscription(gameId)
|
|
||||||
|
|
||||||
private def handleC2sMessage(gameId: String, msg: String): Unit =
|
private def handleC2sMessage(gameId: String, msg: String): Unit =
|
||||||
parseC2sMessage(msg) match
|
parseC2sMessage(msg) match
|
||||||
case Some(C2sMessage.Connected) => handleConnected(gameId)
|
case Some(C2sMessage.Connected) => handleConnected(gameId)
|
||||||
case Some(C2sMessage.Move(uci)) => handleMove(gameId, uci)
|
case Some(C2sMessage.Move(uci)) => handleMove(gameId, uci)
|
||||||
case Some(C2sMessage.Ping) => ()
|
case Some(C2sMessage.Ping) => ()
|
||||||
case None => ()
|
case None => ()
|
||||||
|
|
||||||
private def handleConnected(gameId: String): Unit =
|
private def handleConnected(gameId: String): Unit =
|
||||||
registry.get(gameId).foreach { entry =>
|
registry.get(gameId).foreach { entry =>
|
||||||
val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
|
val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
|
||||||
val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
|
val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
|
||||||
redisson.getTopic(s2cTopicName(gameId)).publish(json)
|
redisson.getTopic(s2cTopicName(gameId)).publish(json)
|
||||||
}
|
}
|
||||||
@@ -120,7 +129,6 @@ class GameRedisSubscriberManager:
|
|||||||
var count = 0
|
var count = 0
|
||||||
gameIds.forEach { gameId =>
|
gameIds.forEach { gameId =>
|
||||||
unsubscribeGame(gameId)
|
unsubscribeGame(gameId)
|
||||||
registry.remove(gameId)
|
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
count
|
count
|
||||||
@@ -131,9 +139,5 @@ class GameRedisSubscriberManager:
|
|||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
def cleanup(): Unit =
|
def cleanup(): Unit =
|
||||||
c2sListeners.forEach((gameId, listenerId) =>
|
c2sListeners.forEach((gameId, listenerId) => redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId))
|
||||||
redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
|
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
|
||||||
)
|
|
||||||
s2cObservers.forEach((gameId, obs) =>
|
|
||||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
package de.nowchess.chess.redis
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
case class GameWritebackEventDto(
|
case class GameWritebackEventDto(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
pgn: String,
|
pgn: String,
|
||||||
moveCount: Int,
|
moveCount: Int,
|
||||||
whiteId: String,
|
whiteId: String,
|
||||||
whiteName: String,
|
whiteName: String,
|
||||||
blackId: String,
|
blackId: String,
|
||||||
blackName: String,
|
blackName: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
resigned: Boolean,
|
resigned: Boolean,
|
||||||
limitSeconds: Option[Int],
|
limitSeconds: Option[Int],
|
||||||
incrementSeconds: Option[Int],
|
incrementSeconds: Option[Int],
|
||||||
daysPerMove: Option[Int],
|
daysPerMove: Option[Int],
|
||||||
whiteRemainingMs: Option[Long],
|
whiteRemainingMs: Option[Long],
|
||||||
blackRemainingMs: Option[Long],
|
blackRemainingMs: Option[Long],
|
||||||
incrementMs: Option[Long],
|
incrementMs: Option[Long],
|
||||||
clockLastTickAt: Option[Long],
|
clockLastTickAt: Option[Long],
|
||||||
clockMoveDeadline: Option[Long],
|
clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: Option[String],
|
pendingDrawOffer: Option[String],
|
||||||
redoStack: List[String] = Nil,
|
redoStack: List[String] = Nil,
|
||||||
pendingTakebackRequest: Option[String] = None,
|
pendingTakebackRequest: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
package de.nowchess.chess.registry
|
package de.nowchess.chess.registry
|
||||||
|
|
||||||
case class GameCacheDto(
|
case class GameCacheDto(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
whiteId: String,
|
whiteId: String,
|
||||||
whiteName: String,
|
whiteName: String,
|
||||||
blackId: String,
|
blackId: String,
|
||||||
blackName: String,
|
blackName: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
pgn: String,
|
pgn: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
resigned: Boolean,
|
resigned: Boolean,
|
||||||
limitSeconds: Option[Int],
|
limitSeconds: Option[Int],
|
||||||
incrementSeconds: Option[Int],
|
incrementSeconds: Option[Int],
|
||||||
daysPerMove: Option[Int],
|
daysPerMove: Option[Int],
|
||||||
whiteRemainingMs: Option[Long],
|
whiteRemainingMs: Option[Long],
|
||||||
blackRemainingMs: Option[Long],
|
blackRemainingMs: Option[Long],
|
||||||
incrementMs: Option[Long],
|
incrementMs: Option[Long],
|
||||||
clockLastTickAt: Option[Long],
|
clockLastTickAt: Option[Long],
|
||||||
clockMoveDeadline: Option[Long],
|
clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: Option[String],
|
pendingDrawOffer: Option[String],
|
||||||
redoStack: List[String] = Nil,
|
redoStack: List[String] = Nil,
|
||||||
pendingTakebackRequest: Option[String] = None,
|
pendingTakebackRequest: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
|||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
import org.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.annotation.nowarn
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
@@ -27,19 +28,19 @@ import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
|
|||||||
class RedisGameRegistry extends GameRegistry:
|
class RedisGameRegistry extends GameRegistry:
|
||||||
@Inject
|
@Inject
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
var redisson: RedissonClient = uninitialized
|
var redisson: RedissonClient = uninitialized
|
||||||
@Inject var redisConfig: RedisConfig = uninitialized
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
||||||
@Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
|
@Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
|
||||||
@Inject @RestClient var storeClient: StoreServiceClient = uninitialized
|
@Inject @RestClient var storeClient: StoreServiceClient = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
private val localEngines = ConcurrentHashMap[String, GameEntry]()
|
private val localEngines = ConcurrentHashMap[String, GameEntry]()
|
||||||
private val rng = new SecureRandom()
|
private val rng = new SecureRandom()
|
||||||
|
|
||||||
private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
|
private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
|
||||||
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
|
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
|
||||||
|
|
||||||
def generateId(): String =
|
def generateId(): String =
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
@@ -48,7 +49,9 @@ class RedisGameRegistry extends GameRegistry:
|
|||||||
def store(entry: GameEntry): Unit =
|
def store(entry: GameEntry): Unit =
|
||||||
localEngines.put(entry.gameId, entry)
|
localEngines.put(entry.gameId, entry)
|
||||||
val combined = ioClient.exportCombined(entry.engine.context)
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
|
val b = bucket(entry.gameId)
|
||||||
|
b.set(toJson(entry, combined.fen, combined.pgn))
|
||||||
|
(b.expire(30, TimeUnit.MINUTES): @nowarn)
|
||||||
|
|
||||||
def get(gameId: String): Option[GameEntry] =
|
def get(gameId: String): Option[GameEntry] =
|
||||||
Option(localEngines.get(gameId)) match
|
Option(localEngines.get(gameId)) match
|
||||||
@@ -63,7 +66,9 @@ class RedisGameRegistry extends GameRegistry:
|
|||||||
def update(entry: GameEntry): Unit =
|
def update(entry: GameEntry): Unit =
|
||||||
localEngines.put(entry.gameId, entry)
|
localEngines.put(entry.gameId, entry)
|
||||||
val combined = ioClient.exportCombined(entry.engine.context)
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
|
val b = bucket(entry.gameId)
|
||||||
|
b.set(toJson(entry, combined.fen, combined.pgn))
|
||||||
|
(b.expire(30, TimeUnit.MINUTES): @nowarn)
|
||||||
|
|
||||||
private def readRedisDto(gameId: String): Option[GameCacheDto] =
|
private def readRedisDto(gameId: String): Option[GameCacheDto] =
|
||||||
Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json =>
|
Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json =>
|
||||||
@@ -106,7 +111,9 @@ class RedisGameRegistry extends GameRegistry:
|
|||||||
}.toOption
|
}.toOption
|
||||||
.map { case (dto, entry) =>
|
.map { case (dto, entry) =>
|
||||||
localEngines.put(gameId, entry)
|
localEngines.put(gameId, entry)
|
||||||
bucket(gameId).set(objectMapper.writeValueAsString(dto), 30, TimeUnit.MINUTES)
|
val b = bucket(gameId)
|
||||||
|
b.set(objectMapper.writeValueAsString(dto))
|
||||||
|
(b.expire(30, TimeUnit.MINUTES): @nowarn)
|
||||||
entry
|
entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,28 +125,31 @@ class RedisGameRegistry extends GameRegistry:
|
|||||||
case _ => TimeControl.Unlimited
|
case _ => TimeControl.Unlimited
|
||||||
val toColor: String => Color = s => if s == "white" then Color.White else Color.Black
|
val toColor: String => Color = s => if s == "white" then Color.White else Color.Black
|
||||||
val restoredClock: Option[ClockState] =
|
val restoredClock: Option[ClockState] =
|
||||||
dto.clockLastTickAt.map { tick =>
|
dto.clockLastTickAt
|
||||||
LiveClockState(
|
.map { tick =>
|
||||||
whiteRemainingMs = dto.whiteRemainingMs.get,
|
LiveClockState(
|
||||||
blackRemainingMs = dto.blackRemainingMs.get,
|
whiteRemainingMs = dto.whiteRemainingMs.get,
|
||||||
incrementMs = dto.incrementMs.get,
|
blackRemainingMs = dto.blackRemainingMs.get,
|
||||||
lastTickAt = Instant.ofEpochMilli(tick),
|
incrementMs = dto.incrementMs.get,
|
||||||
activeColor = toColor(dto.clockActiveColor.get),
|
lastTickAt = Instant.ofEpochMilli(tick),
|
||||||
)
|
|
||||||
}.orElse {
|
|
||||||
dto.clockMoveDeadline.map { deadline =>
|
|
||||||
CorrespondenceClockState(
|
|
||||||
moveDeadline = Instant.ofEpochMilli(deadline),
|
|
||||||
daysPerMove = dto.daysPerMove.get,
|
|
||||||
activeColor = toColor(dto.clockActiveColor.get),
|
activeColor = toColor(dto.clockActiveColor.get),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.orElse {
|
||||||
val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
|
dto.clockMoveDeadline.map { deadline =>
|
||||||
|
CorrespondenceClockState(
|
||||||
|
moveDeadline = Instant.ofEpochMilli(deadline),
|
||||||
|
daysPerMove = dto.daysPerMove.get,
|
||||||
|
activeColor = toColor(dto.clockActiveColor.get),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
|
||||||
val restoredTakebackRequest = dto.pendingTakebackRequest.map(toColor)
|
val restoredTakebackRequest = dto.pendingTakebackRequest.map(toColor)
|
||||||
val redoMoves = dto.redoStack.flatMap { uci =>
|
val redoMoves = dto.redoStack.flatMap { uci =>
|
||||||
Parser.parseMove(uci).flatMap { case (from, to, pp) =>
|
Parser.parseMove(uci).flatMap { case (from, to, pp) =>
|
||||||
ruleSetAdapter.legalMoves(ctx)(from)
|
ruleSetAdapter
|
||||||
|
.legalMoves(ctx)(from)
|
||||||
.find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
|
.find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,8 +205,8 @@ class RedisGameRegistry extends GameRegistry:
|
|||||||
|
|
||||||
private def entryHash(entry: GameEntry): Option[String] =
|
private def entryHash(entry: GameEntry): Option[String] =
|
||||||
Try {
|
Try {
|
||||||
val combined = ioClient.exportCombined(entry.engine.context)
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
val canonicalJson = objectMapper.writeValueAsString(toDto(entry, combined.fen, combined.pgn))
|
val canonicalJson = objectMapper.writeValueAsString(toDto(entry, combined.fen, combined.pgn))
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
|
val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
|
||||||
digest.map("%02x".format(_)).mkString
|
digest.map("%02x".format(_)).mkString
|
||||||
}.toOption
|
}.toOption
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ object GameDtoMapper:
|
|||||||
else
|
else
|
||||||
val ctx = entry.engine.context
|
val ctx = entry.engine.context
|
||||||
ctx.result match
|
ctx.result match
|
||||||
case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
|
case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
|
||||||
case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
|
case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
|
||||||
case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
|
case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
|
||||||
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
||||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
||||||
case Some(GameResult.Draw(_)) => "draw"
|
case Some(GameResult.Draw(_)) => "draw"
|
||||||
@@ -54,7 +54,7 @@ object GameDtoMapper:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
|
def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
|
||||||
val ctx = entry.engine.context
|
val ctx = entry.engine.context
|
||||||
val exported = ioClient.exportCombined(ctx)
|
val exported = ioClient.exportCombined(ctx)
|
||||||
GameStateDto(
|
GameStateDto(
|
||||||
fen = exported.fen,
|
fen = exported.fen,
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ class GameResource:
|
|||||||
val color = colorOf(entry)
|
val color = colorOf(entry)
|
||||||
action match
|
action match
|
||||||
case "request" => entry.engine.requestTakeback(color); registry.update(entry); ok(OkResponseDto())
|
case "request" => entry.engine.requestTakeback(color); registry.update(entry); ok(OkResponseDto())
|
||||||
case "accept" => entry.engine.acceptTakeback(color); registry.update(entry); ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
case "accept" =>
|
||||||
|
entry.engine.acceptTakeback(color); registry.update(entry); ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
||||||
case "decline" => entry.engine.declineTakeback(color); registry.update(entry); ok(OkResponseDto())
|
case "decline" => entry.engine.declineTakeback(color); registry.update(entry); ok(OkResponseDto())
|
||||||
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown takeback action: $action", Some("action"))
|
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown takeback action: $action", Some("action"))
|
||||||
|
|
||||||
|
|||||||
+42
-40
@@ -7,34 +7,39 @@ import io.quarkus.runtime.StartupEvent
|
|||||||
import io.quarkus.runtime.ShutdownEvent
|
import io.quarkus.runtime.ShutdownEvent
|
||||||
import io.quarkus.grpc.GrpcClient
|
import io.quarkus.grpc.GrpcClient
|
||||||
import org.redisson.api.RedissonClient
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.annotation.nowarn
|
||||||
import scala.concurrent.duration.*
|
import scala.concurrent.duration.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
import java.util.concurrent.{Executors, TimeUnit}
|
import java.util.concurrent.{Executors, TimeUnit}
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import de.nowchess.coordinator.{HeartbeatFrame, CoordinatorServiceGrpc}
|
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||||
import de.nowchess.coordinator.CoordinatorServiceGrpc.CoordinatorServiceStub
|
import de.nowchess.coordinator.proto.CoordinatorServiceGrpc.CoordinatorServiceStub
|
||||||
import io.grpc.stub.StreamObserver
|
import io.grpc.stub.StreamObserver
|
||||||
|
import io.grpc.Channel
|
||||||
import scala.jdk.FutureConverters.*
|
import scala.jdk.FutureConverters.*
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class InstanceHeartbeatService:
|
class InstanceHeartbeatService:
|
||||||
@Inject
|
@Inject
|
||||||
private var redissonClient: RedissonClient = _
|
private var redissonClient: RedissonClient = uninitialized
|
||||||
|
|
||||||
@GrpcClient("coordinator-grpc")
|
@GrpcClient("coordinator-grpc")
|
||||||
private var coordinatorStub: CoordinatorServiceStub = _
|
private var channel: Channel = uninitialized
|
||||||
|
|
||||||
private val log = Logger.getLogger(classOf[InstanceHeartbeatService])
|
private var coordinatorStub: CoordinatorServiceStub = uninitialized
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[InstanceHeartbeatService])
|
||||||
private val mapper = ObjectMapper()
|
private val mapper = ObjectMapper()
|
||||||
|
|
||||||
private var instanceId = ""
|
private var instanceId = ""
|
||||||
private var redisPrefix = "nowchess"
|
private var redisPrefix = "nowchess"
|
||||||
private var streamObserver: Option[StreamObserver[HeartbeatFrame]] = None
|
private var streamObserver: Option[StreamObserver[HeartbeatFrame]] = None
|
||||||
private var heartbeatExecutor = Executors.newScheduledThreadPool(1)
|
private var heartbeatExecutor = Executors.newScheduledThreadPool(1)
|
||||||
private var redisHeartbeatExecutor = Executors.newScheduledThreadPool(1)
|
private var redisHeartbeatExecutor = Executors.newScheduledThreadPool(1)
|
||||||
private var subscriptionCount = 0
|
private var subscriptionCount = 0
|
||||||
private var localCacheSize = 0
|
private var localCacheSize = 0
|
||||||
|
|
||||||
def onStart(@Observes event: StartupEvent): Unit =
|
def onStart(@Observes event: StartupEvent): Unit =
|
||||||
try
|
try
|
||||||
@@ -64,35 +69,34 @@ class InstanceHeartbeatService:
|
|||||||
localCacheSize = count
|
localCacheSize = count
|
||||||
|
|
||||||
def addGameSubscription(gameId: String): Unit =
|
def addGameSubscription(gameId: String): Unit =
|
||||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||||
val gameSet = redissonClient.getSet[String](setKey)
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
gameSet.add(gameId)
|
gameSet.add(gameId)
|
||||||
subscriptionCount += 1
|
subscriptionCount += 1
|
||||||
|
|
||||||
def removeGameSubscription(gameId: String): Unit =
|
def removeGameSubscription(gameId: String): Unit =
|
||||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||||
val gameSet = redissonClient.getSet[String](setKey)
|
val gameSet = redissonClient.getSet[String](setKey)
|
||||||
gameSet.remove(gameId)
|
gameSet.remove(gameId)
|
||||||
subscriptionCount = Math.max(0, subscriptionCount - 1)
|
subscriptionCount = Math.max(0, subscriptionCount - 1)
|
||||||
|
|
||||||
private def generateInstanceId(): Unit =
|
private def generateInstanceId(): Unit =
|
||||||
val hostname = try
|
val hostname =
|
||||||
InetAddress.getLocalHost.getHostName
|
try InetAddress.getLocalHost.getHostName
|
||||||
catch
|
catch case _: Exception => "unknown"
|
||||||
case _: Exception => "unknown"
|
|
||||||
|
|
||||||
val uuid = java.util.UUID.randomUUID().toString.take(8)
|
val uuid = java.util.UUID.randomUUID().toString.take(8)
|
||||||
instanceId = s"$hostname-$uuid"
|
instanceId = s"$hostname-$uuid"
|
||||||
|
|
||||||
private def initializeHeartbeatStream(): Unit =
|
private def initializeHeartbeatStream(): Unit =
|
||||||
try
|
try
|
||||||
val responseObserver = new StreamObserver[de.nowchess.coordinator.CoordinatorCommand]:
|
coordinatorStub = CoordinatorServiceGrpc.newStub(channel)
|
||||||
override def onNext(value: de.nowchess.coordinator.CoordinatorCommand): Unit =
|
val responseObserver = new StreamObserver[CoordinatorCommand]:
|
||||||
|
override def onNext(value: CoordinatorCommand): Unit =
|
||||||
log.debugf("Received coordinator command: %s", value.getType)
|
log.debugf("Received coordinator command: %s", value.getType)
|
||||||
|
|
||||||
override def onError(t: Throwable): Unit =
|
override def onError(t: Throwable): Unit =
|
||||||
log.warnf(t, "Heartbeat stream error")
|
log.warnf(t, "Heartbeat stream error")
|
||||||
// Reconnect on error
|
|
||||||
() // Placeholder for reconnect logic
|
() // Placeholder for reconnect logic
|
||||||
|
|
||||||
override def onCompleted: Unit =
|
override def onCompleted: Unit =
|
||||||
@@ -111,7 +115,7 @@ class InstanceHeartbeatService:
|
|||||||
() => sendHeartbeat(),
|
() => sendHeartbeat(),
|
||||||
0,
|
0,
|
||||||
200,
|
200,
|
||||||
TimeUnit.MILLISECONDS
|
TimeUnit.MILLISECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Refresh Redis TTL every 2s
|
// Refresh Redis TTL every 2s
|
||||||
@@ -119,13 +123,14 @@ class InstanceHeartbeatService:
|
|||||||
() => refreshRedisHeartbeat(),
|
() => refreshRedisHeartbeat(),
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
TimeUnit.SECONDS
|
TimeUnit.SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
private def sendHeartbeat(): Unit =
|
private def sendHeartbeat(): Unit =
|
||||||
streamObserver.foreach { observer =>
|
streamObserver.foreach { observer =>
|
||||||
try
|
try
|
||||||
val frame = HeartbeatFrame.newBuilder()
|
val frame = HeartbeatFrame
|
||||||
|
.newBuilder()
|
||||||
.setInstanceId(instanceId)
|
.setInstanceId(instanceId)
|
||||||
.setHostname(getHostname)
|
.setHostname(getHostname)
|
||||||
.setHttpPort(8080) // Placeholder, should be configurable
|
.setHttpPort(8080) // Placeholder, should be configurable
|
||||||
@@ -142,31 +147,30 @@ class InstanceHeartbeatService:
|
|||||||
|
|
||||||
private def refreshRedisHeartbeat(): Unit =
|
private def refreshRedisHeartbeat(): Unit =
|
||||||
try
|
try
|
||||||
val key = s"$redisPrefix:instances:$instanceId"
|
val key = s"$redisPrefix:instances:$instanceId"
|
||||||
val bucket = redissonClient.getBucket[String](key)
|
val bucket = redissonClient.getBucket[String](key)
|
||||||
|
|
||||||
val metadata = Map(
|
val metadata = Map(
|
||||||
"instanceId" -> instanceId,
|
"instanceId" -> instanceId,
|
||||||
"hostname" -> getHostname,
|
"hostname" -> getHostname,
|
||||||
"httpPort" -> 8080,
|
"httpPort" -> 8080,
|
||||||
"grpcPort" -> 9080,
|
"grpcPort" -> 9080,
|
||||||
"subscriptionCount" -> subscriptionCount,
|
"subscriptionCount" -> subscriptionCount,
|
||||||
"localCacheSize" -> localCacheSize,
|
"localCacheSize" -> localCacheSize,
|
||||||
"lastHeartbeat" -> java.time.Instant.now().toString,
|
"lastHeartbeat" -> java.time.Instant.now().toString,
|
||||||
"state" -> "HEALTHY"
|
"state" -> "HEALTHY",
|
||||||
)
|
)
|
||||||
|
|
||||||
val json = mapper.writeValueAsString(metadata)
|
val json = mapper.writeValueAsString(metadata)
|
||||||
bucket.set(json, 5, TimeUnit.SECONDS) // 5-second TTL, refreshed every 2s
|
bucket.set(json)
|
||||||
|
(bucket.expire(5, TimeUnit.SECONDS): @nowarn)
|
||||||
catch
|
catch
|
||||||
case ex: Exception =>
|
case ex: Exception =>
|
||||||
log.warnf(ex, "Failed to refresh Redis heartbeat")
|
log.warnf(ex, "Failed to refresh Redis heartbeat")
|
||||||
|
|
||||||
private def getHostname: String =
|
private def getHostname: String =
|
||||||
try
|
try InetAddress.getLocalHost.getHostName
|
||||||
InetAddress.getLocalHost.getHostName
|
catch case _: Exception => "unknown"
|
||||||
catch
|
|
||||||
case _: Exception => "unknown"
|
|
||||||
|
|
||||||
private def cleanup(): Unit =
|
private def cleanup(): Unit =
|
||||||
streamObserver.foreach(_.onCompleted())
|
streamObserver.foreach(_.onCompleted())
|
||||||
@@ -180,7 +184,5 @@ class InstanceHeartbeatService:
|
|||||||
|
|
||||||
heartbeatExecutor.shutdown()
|
heartbeatExecutor.shutdown()
|
||||||
redisHeartbeatExecutor.shutdown()
|
redisHeartbeatExecutor.shutdown()
|
||||||
if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then
|
if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then heartbeatExecutor.shutdownNow()
|
||||||
heartbeatExecutor.shutdownNow()
|
if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then redisHeartbeatExecutor.shutdownNow()
|
||||||
if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then
|
|
||||||
redisHeartbeatExecutor.shutdownNow()
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ quarkus:
|
|||||||
io-grpc:
|
io-grpc:
|
||||||
host: localhost
|
host: localhost
|
||||||
port: 9081
|
port: 9081
|
||||||
|
rest-client:
|
||||||
|
store-service:
|
||||||
|
url: http://localhost:8085
|
||||||
|
|
||||||
nowchess:
|
nowchess:
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{File, Rank, Square}
|
|
||||||
import de.nowchess.api.game.GameContext
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
private case class FailingCommand() extends Command:
|
|
||||||
override def execute(): Boolean = false
|
|
||||||
override def undo(): Boolean = false
|
|
||||||
override def description: String = "Failing command"
|
|
||||||
|
|
||||||
private class ConditionalFailCommand(
|
|
||||||
initialShouldFailOnUndo: Boolean = false,
|
|
||||||
initialShouldFailOnExecute: Boolean = false,
|
|
||||||
) extends Command:
|
|
||||||
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
|
|
||||||
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
|
|
||||||
override def execute(): Boolean = !shouldFailOnExecute.get()
|
|
||||||
override def undo(): Boolean = !shouldFailOnUndo.get()
|
|
||||||
override def description: String = "Conditional fail"
|
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
|
||||||
MoveCommand(
|
|
||||||
from = from,
|
|
||||||
to = to,
|
|
||||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
|
|
||||||
previousContext = Some(GameContext.initial),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("execute rejects failing commands and keeps history unchanged"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = FailingCommand()
|
|
||||||
invoker.execute(cmd) shouldBe false
|
|
||||||
invoker.history.size shouldBe 0
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
|
|
||||||
val failingCmd = FailingCommand()
|
|
||||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(failingCmd) shouldBe false
|
|
||||||
invoker.history.size shouldBe 0
|
|
||||||
invoker.execute(successCmd) shouldBe true
|
|
||||||
invoker.history.size shouldBe 1
|
|
||||||
invoker.history.head shouldBe successCmd
|
|
||||||
|
|
||||||
test("undo redo and history trimming cover all command state transitions"):
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
invoker.undo() shouldBe false
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
invoker.undo() shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.undo()
|
|
||||||
invoker.undo() shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
|
|
||||||
invoker.execute(failingUndoCmd) shouldBe true
|
|
||||||
invoker.canUndo shouldBe true
|
|
||||||
invoker.undo() shouldBe false
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val successUndoCmd = ConditionalFailCommand()
|
|
||||||
invoker.execute(successUndoCmd) shouldBe true
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val redoFailCmd = ConditionalFailCommand()
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(redoFailCmd)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
redoFailCmd.shouldFailOnExecute.set(true)
|
|
||||||
invoker.redo() shouldBe false
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd) shouldBe true
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.redo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.history.size shouldBe 2
|
|
||||||
invoker.history(1) shouldBe cmd3
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
|
||||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.execute(cmd4)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.undo()
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
|
||||||
invoker.execute(newCmd)
|
|
||||||
invoker.history.size shouldBe 3
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{File, Rank, Square}
|
|
||||||
import de.nowchess.api.game.GameContext
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
|
||||||
MoveCommand(
|
|
||||||
from = from,
|
|
||||||
to = to,
|
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
|
||||||
previousContext = Some(GameContext.initial),
|
|
||||||
)
|
|
||||||
|
|
||||||
test("execute appends commands and updates index"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd) shouldBe true
|
|
||||||
invoker.history.size shouldBe 1
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
invoker.execute(cmd2) shouldBe true
|
|
||||||
invoker.history.size shouldBe 2
|
|
||||||
invoker.getCurrentIndex shouldBe 1
|
|
||||||
|
|
||||||
test("undo and redo update index and availability flags"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.canUndo shouldBe false
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.canUndo shouldBe true
|
|
||||||
invoker.undo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
invoker.redo() shouldBe true
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
|
|
||||||
test("clear removes full history and resets index"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
invoker.execute(cmd)
|
|
||||||
invoker.clear()
|
|
||||||
invoker.history.size shouldBe 0
|
|
||||||
invoker.getCurrentIndex shouldBe -1
|
|
||||||
|
|
||||||
test("execute after undo discards redo history"):
|
|
||||||
val invoker = new CommandInvoker()
|
|
||||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
|
||||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
|
||||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
|
||||||
invoker.execute(cmd1)
|
|
||||||
invoker.execute(cmd2)
|
|
||||||
invoker.undo()
|
|
||||||
invoker.getCurrentIndex shouldBe 0
|
|
||||||
invoker.canRedo shouldBe true
|
|
||||||
invoker.execute(cmd3)
|
|
||||||
invoker.canRedo shouldBe false
|
|
||||||
invoker.history.size shouldBe 2
|
|
||||||
invoker.history.head shouldBe cmd1
|
|
||||||
invoker.history(1) shouldBe cmd3
|
|
||||||
invoker.getCurrentIndex shouldBe 1
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.game.GameContext
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class CommandTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("QuitCommand properties and behavior"):
|
|
||||||
val cmd = QuitCommand()
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
cmd.description shouldBe "Quit game"
|
|
||||||
|
|
||||||
test("ResetCommand behavior depends on previousContext"):
|
|
||||||
val noState = ResetCommand()
|
|
||||||
noState.execute() shouldBe true
|
|
||||||
noState.undo() shouldBe false
|
|
||||||
noState.description shouldBe "Reset board"
|
|
||||||
|
|
||||||
val withState = ResetCommand(previousContext = Some(GameContext.initial))
|
|
||||||
withState.execute() shouldBe true
|
|
||||||
withState.undo() shouldBe true
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{File, Rank, Square}
|
|
||||||
import de.nowchess.api.game.GameContext
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveCommandTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
test("MoveCommand defaults to empty optional state and false execute/undo"):
|
|
||||||
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
|
||||||
cmd.moveResult shouldBe None
|
|
||||||
cmd.previousContext shouldBe None
|
|
||||||
cmd.execute() shouldBe false
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
cmd.description shouldBe "Move from e2 to e4"
|
|
||||||
|
|
||||||
test("MoveCommand execute/undo succeed when state is present"):
|
|
||||||
val executable = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
|
||||||
)
|
|
||||||
executable.execute() shouldBe true
|
|
||||||
|
|
||||||
val undoable = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
|
|
||||||
previousContext = Some(GameContext.initial),
|
|
||||||
)
|
|
||||||
undoable.undo() shouldBe true
|
|
||||||
|
|
||||||
test("MoveCommand is immutable and preserves equality/hash semantics"):
|
|
||||||
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
|
|
||||||
|
|
||||||
val result = MoveResult.Successful(GameContext.initial, None)
|
|
||||||
val cmd2 = cmd1.copy(
|
|
||||||
moveResult = Some(result),
|
|
||||||
previousContext = Some(GameContext.initial),
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd1.moveResult shouldBe None
|
|
||||||
cmd1.previousContext shouldBe None
|
|
||||||
|
|
||||||
cmd2.moveResult shouldBe Some(result)
|
|
||||||
cmd2.previousContext shouldBe Some(GameContext.initial)
|
|
||||||
|
|
||||||
val eq1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousContext = None,
|
|
||||||
)
|
|
||||||
|
|
||||||
val eq2 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = None,
|
|
||||||
previousContext = None,
|
|
||||||
)
|
|
||||||
|
|
||||||
eq1 shouldBe eq2
|
|
||||||
eq1.hashCode shouldBe eq2.hashCode
|
|
||||||
|
|
||||||
val hash1 = eq1.hashCode
|
|
||||||
val hash2 = eq1.hashCode
|
|
||||||
hash1 shouldBe hash2
|
|
||||||
+1
-25
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
|||||||
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
|
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
|
||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
|
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer}
|
||||||
import de.nowchess.api.error.GameError
|
import de.nowchess.api.error.GameError
|
||||||
import de.nowchess.api.io.GameContextImport
|
import de.nowchess.api.io.GameContextImport
|
||||||
import de.nowchess.api.rules.RuleSet
|
import de.nowchess.api.rules.RuleSet
|
||||||
@@ -21,15 +21,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.subscribe((event: GameEvent) => events += event)
|
engine.subscribe((event: GameEvent) => events += event)
|
||||||
events
|
events
|
||||||
|
|
||||||
test("accessors expose redo availability and command history"):
|
|
||||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
|
||||||
|
|
||||||
engine.canRedo shouldBe false
|
|
||||||
engine.commandHistory shouldBe empty
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.commandHistory.nonEmpty shouldBe true
|
|
||||||
|
|
||||||
test("processUserInput handles undo redo empty and malformed commands"):
|
test("processUserInput handles undo redo empty and malformed commands"):
|
||||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
val engine = new GameEngine(ruleSet = DefaultRules)
|
||||||
val events = captureEvents(engine)
|
val events = captureEvents(engine)
|
||||||
@@ -72,26 +63,11 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
|||||||
engine.loadPosition(target)
|
engine.loadPosition(target)
|
||||||
|
|
||||||
engine.context shouldBe target
|
engine.context shouldBe target
|
||||||
engine.commandHistory shouldBe empty
|
|
||||||
events.lastOption.exists {
|
events.lastOption.exists {
|
||||||
case _: de.nowchess.chess.observer.BoardResetEvent => true
|
case _: de.nowchess.chess.observer.BoardResetEvent => true
|
||||||
case _ => false
|
case _ => false
|
||||||
} shouldBe true
|
} shouldBe true
|
||||||
|
|
||||||
test("redo event includes captured piece description when replaying a capture"):
|
|
||||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
|
||||||
val events = captureEvents(engine)
|
|
||||||
|
|
||||||
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
|
|
||||||
events.clear()
|
|
||||||
|
|
||||||
engine.processUserInput("a1h1")
|
|
||||||
engine.processUserInput("undo")
|
|
||||||
engine.processUserInput("redo")
|
|
||||||
|
|
||||||
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
|
|
||||||
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
|
|
||||||
|
|
||||||
test("loadGame replay handles promotion moves when pending promotion exists"):
|
test("loadGame replay handles promotion moves when pending promotion exists"):
|
||||||
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
package de.nowchess.chess.registry
|
package de.nowchess.chess.registry
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.client.{CombinedExportResponse, StoreServiceClient}
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.io.fen.FenExporter
|
||||||
|
import de.nowchess.io.pgn.PgnExporter
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
import de.nowchess.chess.engine.GameEngine
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import io.quarkus.test.InjectMock
|
||||||
import io.quarkus.test.junit.QuarkusTest
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import org.junit.jupiter.api.{DisplayName, Test}
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
|
||||||
import org.junit.jupiter.api.Assertions.*
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.mockito.ArgumentMatchers.any
|
||||||
|
import org.mockito.Mockito.when
|
||||||
|
import org.mockito.invocation.InvocationOnMock
|
||||||
|
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
@@ -18,6 +28,25 @@ class GameRegistryImplTest:
|
|||||||
@Inject
|
@Inject
|
||||||
var registry: GameRegistry = uninitialized
|
var registry: GameRegistry = uninitialized
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
var ioWrapper: IoGrpcClientWrapper = uninitialized
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
@RestClient
|
||||||
|
var storeClient: StoreServiceClient = uninitialized
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setupMocks(): Unit =
|
||||||
|
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
|
val ctx = inv.getArgument[GameContext](0)
|
||||||
|
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
|
||||||
|
)
|
||||||
|
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
|
de.nowchess.io.pgn.PgnParser
|
||||||
|
.importGameContext(inv.getArgument[String](0))
|
||||||
|
.getOrElse(GameContext.initial),
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("store saves entry")
|
@DisplayName("store saves entry")
|
||||||
def testStore(): Unit =
|
def testStore(): Unit =
|
||||||
|
|||||||
+7
-5
@@ -7,7 +7,7 @@ import de.nowchess.chess.client.CombinedExportResponse
|
|||||||
import de.nowchess.chess.exception.BadRequestException
|
import de.nowchess.chess.exception.BadRequestException
|
||||||
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
||||||
import de.nowchess.io.fen.FenExporter
|
import de.nowchess.io.fen.FenExporter
|
||||||
import de.nowchess.io.pgn.PgnParser
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
import io.quarkus.test.InjectMock
|
import io.quarkus.test.InjectMock
|
||||||
import io.quarkus.test.junit.QuarkusTest
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
@@ -37,17 +37,19 @@ class GameResourceIntegrationTest:
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
def setupMocks(): Unit =
|
def setupMocks(): Unit =
|
||||||
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
|
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
|
||||||
when(ioWrapper.importPgn(any[String]())).thenReturn(
|
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
PgnParser.importGameContext("1. e4 c5").toOption.get,
|
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
|
||||||
)
|
)
|
||||||
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
|
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
val ctx = inv.getArgument[GameContext](0)
|
val ctx = inv.getArgument[GameContext](0)
|
||||||
CombinedExportResponse(FenExporter.exportGameContext(ctx), ""),
|
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
|
||||||
)
|
)
|
||||||
when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
|
when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
|
FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
|
||||||
)
|
)
|
||||||
when(ioWrapper.exportPgn(any())).thenReturn("")
|
when(ioWrapper.exportPgn(any())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
|
PgnExporter.exportGameContext(inv.getArgument[GameContext](0)),
|
||||||
|
)
|
||||||
|
|
||||||
when(ruleAdapter.legalMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
|
when(ruleAdapter.legalMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
DefaultRules.legalMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
|
DefaultRules.legalMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
|
||||||
|
|||||||
@@ -39,10 +39,22 @@ class IoGrpcService extends IoServiceGrpc.IoServiceImplBase:
|
|||||||
)
|
)
|
||||||
|
|
||||||
override def exportFen(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
|
override def exportFen(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
|
||||||
respond(resp, ProtoStringResult.newBuilder().setValue(FenExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req))).build())
|
respond(
|
||||||
|
resp,
|
||||||
|
ProtoStringResult
|
||||||
|
.newBuilder()
|
||||||
|
.setValue(FenExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req)))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
override def exportPgn(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
|
override def exportPgn(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
|
||||||
respond(resp, ProtoStringResult.newBuilder().setValue(PgnExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req))).build())
|
respond(
|
||||||
|
resp,
|
||||||
|
ProtoStringResult
|
||||||
|
.newBuilder()
|
||||||
|
.setValue(PgnExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req)))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
private def respond[T](obs: StreamObserver[T], value: T): Unit =
|
private def respond[T](obs: StreamObserver[T], value: T): Unit =
|
||||||
obs.onNext(value)
|
obs.onNext(value)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.io.grpc
|
package de.nowchess.io.grpc
|
||||||
|
|
||||||
import de.nowchess.api.board.*
|
import de.nowchess.api.board.*
|
||||||
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights}
|
import de.nowchess.api.board.CastlingRights as DomainCastlingRights
|
||||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
||||||
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
||||||
import de.nowchess.io.proto.*
|
import de.nowchess.io.proto.*
|
||||||
@@ -58,7 +58,12 @@ object IoProtoMapper:
|
|||||||
case _ => MoveType.Normal(false)
|
case _ => MoveType.Normal(false)
|
||||||
|
|
||||||
def toProtoMove(m: DomainMove): ProtoMove =
|
def toProtoMove(m: DomainMove): ProtoMove =
|
||||||
ProtoMove.newBuilder().setFrom(m.from.toString).setTo(m.to.toString).setMoveKind(toProtoMoveKind(m.moveType)).build()
|
ProtoMove
|
||||||
|
.newBuilder()
|
||||||
|
.setFrom(m.from.toString)
|
||||||
|
.setTo(m.to.toString)
|
||||||
|
.setMoveKind(toProtoMoveKind(m.moveType))
|
||||||
|
.build()
|
||||||
|
|
||||||
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
||||||
for
|
for
|
||||||
@@ -67,16 +72,33 @@ object IoProtoMapper:
|
|||||||
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
||||||
|
|
||||||
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
||||||
board.pieces.map { (sq, piece) =>
|
board.pieces
|
||||||
ProtoSquarePiece
|
.map { (sq, piece) =>
|
||||||
.newBuilder()
|
ProtoSquarePiece
|
||||||
.setSquare(sq.toString)
|
.newBuilder()
|
||||||
.setPiece(ProtoPiece.newBuilder().setColor(toProtoColor(piece.color)).setPieceType(toProtoPieceType(piece.pieceType)).build())
|
.setSquare(sq.toString)
|
||||||
.build()
|
.setPiece(
|
||||||
}.toSeq.asJava
|
ProtoPiece
|
||||||
|
.newBuilder()
|
||||||
|
.setColor(toProtoColor(piece.color))
|
||||||
|
.setPieceType(toProtoPieceType(piece.pieceType))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.toSeq
|
||||||
|
.asJava
|
||||||
|
|
||||||
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
||||||
Board(pieces.asScala.flatMap(sp => Square.fromAlgebraic(sp.getSquare).map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))).toMap)
|
Board(
|
||||||
|
pieces.asScala
|
||||||
|
.flatMap(sp =>
|
||||||
|
Square
|
||||||
|
.fromAlgebraic(sp.getSquare)
|
||||||
|
.map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType))),
|
||||||
|
)
|
||||||
|
.toMap,
|
||||||
|
)
|
||||||
|
|
||||||
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
||||||
case None => ProtoGameResultKind.ONGOING
|
case None => ProtoGameResultKind.ONGOING
|
||||||
@@ -131,12 +153,13 @@ object IoProtoMapper:
|
|||||||
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
||||||
val cr = p.getCastlingRights
|
val cr = p.getCastlingRights
|
||||||
GameContext(
|
GameContext(
|
||||||
board = fromProtoBoard(p.getBoardList),
|
board = fromProtoBoard(p.getBoardList),
|
||||||
turn = fromProtoColor(p.getTurn),
|
turn = fromProtoColor(p.getTurn),
|
||||||
castlingRights = DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
castlingRights =
|
||||||
|
DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
||||||
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
||||||
halfMoveClock = p.getHalfMoveClock,
|
halfMoveClock = p.getHalfMoveClock,
|
||||||
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
||||||
result = fromProtoResultKind(p.getResult),
|
result = fromProtoResultKind(p.getResult),
|
||||||
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ tasks.jar {
|
|||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
tasks.withType(ScalaCompile::class).configureEach {
|
||||||
if (name == "compileScoverageScala") {
|
if (name == "compileScoverageScala") {
|
||||||
source = source.asFileTree.matching {
|
source = source.asFileTree.matching {
|
||||||
exclude("**/grpc/*.scala")
|
exclude("**/grpc/*.scala")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.nowchess.rules.grpc
|
|||||||
|
|
||||||
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
|
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
|
||||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
||||||
import de.nowchess.api.move.{MoveType, PromotionPiece, Move as DomainMove}
|
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
||||||
import de.nowchess.rules.proto.*
|
import de.nowchess.rules.proto.*
|
||||||
|
|
||||||
import scala.jdk.CollectionConverters.*
|
import scala.jdk.CollectionConverters.*
|
||||||
@@ -57,7 +57,12 @@ object ProtoMapper:
|
|||||||
case _ => MoveType.Normal(false)
|
case _ => MoveType.Normal(false)
|
||||||
|
|
||||||
def toProtoMove(m: DomainMove): ProtoMove =
|
def toProtoMove(m: DomainMove): ProtoMove =
|
||||||
ProtoMove.newBuilder().setFrom(m.from.toString).setTo(m.to.toString).setMoveKind(toProtoMoveKind(m.moveType)).build()
|
ProtoMove
|
||||||
|
.newBuilder()
|
||||||
|
.setFrom(m.from.toString)
|
||||||
|
.setTo(m.to.toString)
|
||||||
|
.setMoveKind(toProtoMoveKind(m.moveType))
|
||||||
|
.build()
|
||||||
|
|
||||||
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
def fromProtoMove(m: ProtoMove): Option[DomainMove] =
|
||||||
for
|
for
|
||||||
@@ -66,16 +71,33 @@ object ProtoMapper:
|
|||||||
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
|
||||||
|
|
||||||
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
|
||||||
board.pieces.map { (sq, piece) =>
|
board.pieces
|
||||||
ProtoSquarePiece
|
.map { (sq, piece) =>
|
||||||
.newBuilder()
|
ProtoSquarePiece
|
||||||
.setSquare(sq.toString)
|
.newBuilder()
|
||||||
.setPiece(ProtoPiece.newBuilder().setColor(toProtoColor(piece.color)).setPieceType(toProtoPieceType(piece.pieceType)).build())
|
.setSquare(sq.toString)
|
||||||
.build()
|
.setPiece(
|
||||||
}.toSeq.asJava
|
ProtoPiece
|
||||||
|
.newBuilder()
|
||||||
|
.setColor(toProtoColor(piece.color))
|
||||||
|
.setPieceType(toProtoPieceType(piece.pieceType))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.toSeq
|
||||||
|
.asJava
|
||||||
|
|
||||||
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
|
||||||
Board(pieces.asScala.flatMap(sp => Square.fromAlgebraic(sp.getSquare).map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))).toMap)
|
Board(
|
||||||
|
pieces.asScala
|
||||||
|
.flatMap(sp =>
|
||||||
|
Square
|
||||||
|
.fromAlgebraic(sp.getSquare)
|
||||||
|
.map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType))),
|
||||||
|
)
|
||||||
|
.toMap,
|
||||||
|
)
|
||||||
|
|
||||||
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
|
||||||
case None => ProtoGameResultKind.ONGOING
|
case None => ProtoGameResultKind.ONGOING
|
||||||
@@ -130,12 +152,13 @@ object ProtoMapper:
|
|||||||
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
def fromProtoGameContext(p: ProtoGameContext): GameContext =
|
||||||
val cr = p.getCastlingRights
|
val cr = p.getCastlingRights
|
||||||
GameContext(
|
GameContext(
|
||||||
board = fromProtoBoard(p.getBoardList),
|
board = fromProtoBoard(p.getBoardList),
|
||||||
turn = fromProtoColor(p.getTurn),
|
turn = fromProtoColor(p.getTurn),
|
||||||
castlingRights = DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
castlingRights =
|
||||||
|
DomainCastlingRights(cr.getWhiteKingSide, cr.getWhiteQueenSide, cr.getBlackKingSide, cr.getBlackQueenSide),
|
||||||
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
|
||||||
halfMoveClock = p.getHalfMoveClock,
|
halfMoveClock = p.getHalfMoveClock,
|
||||||
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
|
||||||
result = fromProtoResultKind(p.getResult),
|
result = fromProtoResultKind(p.getResult),
|
||||||
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
initialBoard = fromProtoBoard(p.getInitialBoardList),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,15 +12,22 @@ import io.quarkus.grpc.GrpcService
|
|||||||
class RuleGrpcService extends RuleServiceGrpc.RuleServiceImplBase:
|
class RuleGrpcService extends RuleServiceGrpc.RuleServiceImplBase:
|
||||||
|
|
||||||
private def parseSquare(s: String): Square =
|
private def parseSquare(s: String): Square =
|
||||||
Square.fromAlgebraic(s).getOrElse(
|
Square
|
||||||
throw Status.INVALID_ARGUMENT.withDescription(s"Invalid square: $s").asRuntimeException(),
|
.fromAlgebraic(s)
|
||||||
)
|
.getOrElse(
|
||||||
|
throw Status.INVALID_ARGUMENT.withDescription(s"Invalid square: $s").asRuntimeException(),
|
||||||
|
)
|
||||||
|
|
||||||
override def candidateMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
|
override def candidateMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
|
||||||
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
|
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
|
||||||
val sq = parseSquare(req.getSquare)
|
val sq = parseSquare(req.getSquare)
|
||||||
val moves = DefaultRules.candidateMoves(ctx)(sq)
|
val moves = DefaultRules.candidateMoves(ctx)(sq)
|
||||||
resp.onNext(ProtoMoveList.newBuilder().addAllMoves(moves.map(ProtoMapper.toProtoMove).asInstanceOf[java.util.List[ProtoMove]]).build())
|
resp.onNext(
|
||||||
|
ProtoMoveList
|
||||||
|
.newBuilder()
|
||||||
|
.addAllMoves(moves.map(ProtoMapper.toProtoMove).asInstanceOf[java.util.List[ProtoMove]])
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
resp.onCompleted()
|
resp.onCompleted()
|
||||||
|
|
||||||
override def legalMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
|
override def legalMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
|
||||||
@@ -52,10 +59,12 @@ class RuleGrpcService extends RuleServiceGrpc.RuleServiceImplBase:
|
|||||||
respond(resp, boolResult(DefaultRules.isThreefoldRepetition(ProtoMapper.fromProtoGameContext(req))))
|
respond(resp, boolResult(DefaultRules.isThreefoldRepetition(ProtoMapper.fromProtoGameContext(req))))
|
||||||
|
|
||||||
override def applyMove(req: ProtoMoveRequest, resp: StreamObserver[ProtoGameContext]): Unit =
|
override def applyMove(req: ProtoMoveRequest, resp: StreamObserver[ProtoGameContext]): Unit =
|
||||||
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
|
val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
|
||||||
val move = ProtoMapper.fromProtoMove(req.getMove).getOrElse(
|
val move = ProtoMapper
|
||||||
throw Status.INVALID_ARGUMENT.withDescription("Invalid move").asRuntimeException(),
|
.fromProtoMove(req.getMove)
|
||||||
)
|
.getOrElse(
|
||||||
|
throw Status.INVALID_ARGUMENT.withDescription("Invalid move").asRuntimeException(),
|
||||||
|
)
|
||||||
respond(resp, ProtoMapper.toProtoGameContext(DefaultRules.applyMove(ctx)(move)))
|
respond(resp, ProtoMapper.toProtoGameContext(DefaultRules.applyMove(ctx)(move)))
|
||||||
|
|
||||||
override def postMoveStatus(req: ProtoGameContext, resp: StreamObserver[ProtoPostMoveStatus]): Unit =
|
override def postMoveStatus(req: ProtoGameContext, resp: StreamObserver[ProtoPostMoveStatus]): Unit =
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
quarkus:
|
||||||
|
application.name: nowchess-store
|
||||||
|
http.port: 8085
|
||||||
|
config.yaml.enabled: true
|
||||||
|
datasource:
|
||||||
|
db-kind: postgresql
|
||||||
|
username: ${DB_USER:nowchess}
|
||||||
|
password: ${DB_PASSWORD:nowchess}
|
||||||
|
jdbc.url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess}
|
||||||
|
hibernate-orm:
|
||||||
|
database.generation: update
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
|
|
||||||
|
"%test":
|
||||||
|
quarkus:
|
||||||
|
datasource:
|
||||||
|
db-kind: h2
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
jdbc.url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
hibernate-orm:
|
||||||
|
database.generation: drop-and-create
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: test-store
|
||||||
@@ -18,7 +18,8 @@ class RedissonProducer:
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
def redissonClient(): RedissonClient =
|
def redissonClient(): RedissonClient =
|
||||||
val config = new Config()
|
val config = new Config()
|
||||||
config.useSingleServer()
|
config
|
||||||
|
.useSingleServer()
|
||||||
.setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
.setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
||||||
.setConnectionMinimumIdleSize(1)
|
.setConnectionMinimumIdleSize(1)
|
||||||
.setConnectTimeout(500)
|
.setConnectTimeout(500)
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
package de.nowchess.store.redis
|
package de.nowchess.store.redis
|
||||||
|
|
||||||
case class GameWritebackEventDto(
|
case class GameWritebackEventDto(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
pgn: String,
|
pgn: String,
|
||||||
moveCount: Int,
|
moveCount: Int,
|
||||||
whiteId: String,
|
whiteId: String,
|
||||||
whiteName: String,
|
whiteName: String,
|
||||||
blackId: String,
|
blackId: String,
|
||||||
blackName: String,
|
blackName: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
resigned: Boolean,
|
resigned: Boolean,
|
||||||
limitSeconds: Option[Int],
|
limitSeconds: Option[Int],
|
||||||
incrementSeconds: Option[Int],
|
incrementSeconds: Option[Int],
|
||||||
daysPerMove: Option[Int],
|
daysPerMove: Option[Int],
|
||||||
whiteRemainingMs: Option[Long],
|
whiteRemainingMs: Option[Long],
|
||||||
blackRemainingMs: Option[Long],
|
blackRemainingMs: Option[Long],
|
||||||
incrementMs: Option[Long],
|
incrementMs: Option[Long],
|
||||||
clockLastTickAt: Option[Long],
|
clockLastTickAt: Option[Long],
|
||||||
clockMoveDeadline: Option[Long],
|
clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: Option[String],
|
pendingDrawOffer: Option[String],
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-7
@@ -14,17 +14,18 @@ import scala.util.Try
|
|||||||
class GameWritebackStreamListener:
|
class GameWritebackStreamListener:
|
||||||
@Inject
|
@Inject
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
var redisson: RedissonClient = uninitialized
|
var redisson: RedissonClient = uninitialized
|
||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var writebackService: GameWritebackService = uninitialized
|
@Inject var writebackService: GameWritebackService = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
def startListening(): Unit =
|
def startListening(): Unit =
|
||||||
val topic = redisson.getTopic("game-writeback")
|
val topic = redisson.getTopic("game-writeback")
|
||||||
topic.addListener(classOf[String], new MessageListener[String]:
|
topic.addListener(
|
||||||
def onMessage(channel: CharSequence, json: String): Unit =
|
classOf[String],
|
||||||
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto]))
|
new MessageListener[String]:
|
||||||
.toOption
|
def onMessage(channel: CharSequence, json: String): Unit =
|
||||||
.foreach(writebackService.writeBack)
|
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
|
||||||
|
.foreach(writebackService.writeBack),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ class StoreGameResource:
|
|||||||
@Path("/{gameId}")
|
@Path("/{gameId}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def getGame(@PathParam("gameId") gameId: String): Response =
|
def getGame(@PathParam("gameId") gameId: String): Response =
|
||||||
repository.findByGameId(gameId)
|
repository
|
||||||
|
.findByGameId(gameId)
|
||||||
.fold(Response.status(404).build())(r => Response.ok(r).build())
|
.fold(Response.status(404).build())(r => Response.ok(r).build())
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ class GameWebSocketResource:
|
|||||||
@OnOpen
|
@OnOpen
|
||||||
def onOpen(connection: WebSocketConnection): Unit =
|
def onOpen(connection: WebSocketConnection): Unit =
|
||||||
val gameId = connection.pathParam("gameId")
|
val gameId = connection.pathParam("gameId")
|
||||||
val topic = redisson.getTopic(s2cTopic(gameId))
|
val topic = redisson.getTopic(s2cTopic(gameId))
|
||||||
val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
|
val listenerId = topic.addListener(
|
||||||
def onMessage(channel: CharSequence, msg: String): Unit =
|
classOf[String],
|
||||||
connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
|
new MessageListener[String]:
|
||||||
|
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||||
|
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
|
||||||
)
|
)
|
||||||
listenerIds.put(connection.id(), (gameId, listenerId))
|
listenerIds.put(connection.id(), (gameId, listenerId))
|
||||||
val connectedMsg = s"""{"type":"CONNECTED","gameId":"$gameId"}"""
|
val connectedMsg = s"""{"type":"CONNECTED","gameId":"$gameId"}"""
|
||||||
|
|||||||
Reference in New Issue
Block a user