138 lines
11 KiB
Markdown
138 lines
11 KiB
Markdown
|
|
# Quarkus backend: design
|
||
|
|
|
||
|
|
See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. The behaviour being ported is
|
||
|
|
described in `keycloak-auth`, `game-history` and `game-clock`; this document covers how the Java backend is built.
|
||
|
|
|
||
|
|
## Decisions
|
||
|
|
|
||
|
|
| Decision | Reason |
|
||
|
|
|---|---|
|
||
|
|
| Quarkus 3.40 (latest stable; 4.0 is in beta), Java 25, JVM mode | R1. Java 25 is the current LTS. Native images would need reflection config for the JSON models and buy little for one long-running process. |
|
||
|
|
| A port of the rules to Java (`app.playmancala.engine`) instead of running `mancala.js` in the JVM (GraalJS) | The engine is about 500 lines with no dependencies; a port is simpler to run, debug and test than an embedded JS runtime. The clients keep using the TypeScript engine for display. |
|
||
|
|
| Engine parity is tested against games recorded with the TypeScript engine | R2.2. A Node script plays seeded random games (including illegal moves) with `packages/mancala.js` and writes every move and the resulting game JSON to a fixture; a Java test replays them. Run the script again after changing the rules. |
|
||
|
|
| All game state is owned by one thread, the **game loop** | The Node backend relies on running on one thread: `GameManager`, the stores, matchmaking, live previews and the WebSocket registry have no locks. A single-threaded scheduled executor keeps that model, so the game logic carries over line by line without races. WebSocket events, timers and API reads that touch live games run on it. A few hundred games are far from what one thread handles. |
|
||
|
|
| Blocking I/O never runs on the game loop (except the restore at startup) | Postgres writes run on the recorder's own thread, Valkey writes on a store thread that keeps them in order (a game's `SET` must not overtake its `DEL`), and token checks on a worker. Results that change state are posted back to the loop. Snapshots are copied on the loop, so serialising them elsewhere is safe. |
|
||
|
|
| Plain JDBC with the existing SQL and the existing migration runner (`schema_migrations`, advisory lock) | R5.1. Flyway or Liquibase would have to be baselined on databases the Node backend already migrated. Two migrations and a 40-line runner do not justify it. Hibernate adds nothing for two tables written once per game. |
|
||
|
|
| `quarkus-redis-client` (blocking `RedisDataSource`) for Valkey | Valkey speaks the Redis protocol. Short connect and command timeouts make calls fail fast while Valkey is down, like `enableOfflineQueue: false` did. |
|
||
|
|
| `quarkus-websockets-next` endpoint at `/`, a thin adapter over a framework-free `RtmtServer` | R3.1. The endpoint only forwards open, text and close events to the game loop; `RtmtServer` holds the protocol (auth handshake, timeouts, sockets per user, ping) and is unit-tested with fake sockets and a manual clock. |
|
||
|
|
| jose4j for Keycloak tokens, not `quarkus-oidc` | Tokens arrive in a WebSocket message, not only in an HTTP header, so they are verified in code. jose4j (managed by the Quarkus BOM) fetches and caches the realm's JWKS like `jose` did. The checks are unchanged: RS256, issuer, `sub`, `exp`, `typ = Bearer`, `azp` in `KEYCLOAK_CLIENT_IDS`. |
|
||
|
|
| Explicit JSON models (records) with `@JsonInclude(NON_NULL)` only on optional fields | R3.2, R5. TypeScript leaves out `undefined` but keeps `null`; e.g. `result: null` and `{ game: null }` must stay while `name` and `leftUserId` are left out. |
|
||
|
|
| `DATABASE_URL` becomes `DATABASE_JDBC_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | R7.1. The JDBC driver does not take `postgres://user:pass@host/db`; parsing it ourselves is not worth it. Compose builds the new variables from the same `MANCALA_DB_PASSWORD`. |
|
||
|
|
| The project is built in `apps/backend-quarkus` and replaces `apps/backend` in the last task | Every commit on the branch has a working backend, and the two can be compared side by side; the final move keeps the path that the Dockerfile, compose and CI refer to. |
|
||
|
|
| `mvn quarkus:dev` debug port moved to 5006 | Quarkus dev mode listens for a debugger on 5005, the backend's port. |
|
||
|
|
|
||
|
|
## Layout
|
||
|
|
|
||
|
|
```
|
||
|
|
apps/backend/
|
||
|
|
pom.xml
|
||
|
|
Dockerfile multi-stage: Maven + Temurin 25 JDK, then Temurin 25 JRE
|
||
|
|
scripts/record-engine-games.js writes the engine parity fixture from packages/mancala.js
|
||
|
|
src/main/resources/
|
||
|
|
application.properties
|
||
|
|
db/migrations/001_games.sql, 002_clock.sql
|
||
|
|
src/main/java/app/playmancala/
|
||
|
|
engine/ Board, Pit, PitType, GameStep, MoveHistoryItem, MancalaGame, GameState, GameRule,
|
||
|
|
CommonMancalaGame and the four rules (port of packages/mancala.js)
|
||
|
|
model/ wire types: AuthUser, PlayerInfo, PlayerSummary, UserConnectionInfo, GameUsersConnectionInfo,
|
||
|
|
GameClock, Seat, GameResult, EndReason, GameEndMessage, MoveRecord, GameRecord,
|
||
|
|
FinishedGameSummary, LiveGameSummary, ClientGame, Channels; Json (the shared ObjectMapper)
|
||
|
|
loop/ GameLoop (the game thread), Scheduler (what the logic needs from it), Cancellable
|
||
|
|
auth/ AuthConfig, TokenVerifier, KeycloakTokenVerifier, InvalidTokenException
|
||
|
|
rtmt/ Rtmt (interface used by the game logic), RtmtServer, RtmtSocket, RtmtEndpoint (Quarkus)
|
||
|
|
game/ BackendGame, GameStore, MatchMaker, GameManager, GameClocks, GameClockScheduler,
|
||
|
|
GameSerialization (LiveGameSnapshot), GameRecorder, LiveGames, PublicIds, CrashLog
|
||
|
|
history/ GameHistoryRepository, PostgresGameHistoryRepository, Migrations, GameHistoryService
|
||
|
|
live/ LiveGameStore, ValkeyLiveGameStore, Cache, ValkeyCache
|
||
|
|
http/ GameResource, RootResource, BearerAuth, exception mapper
|
||
|
|
Backend.java wiring and startup (a @Startup bean)
|
||
|
|
```
|
||
|
|
|
||
|
|
The game logic (`engine`, `game`, `history` services, `rtmt.RtmtServer`) is plain Java built with constructors, as
|
||
|
|
the TypeScript classes were, so unit tests build it with fakes. `Backend` creates it from CDI-managed infrastructure
|
||
|
|
(datasource, Redis client, config) and exposes what the HTTP resources and the WebSocket endpoint need.
|
||
|
|
|
||
|
|
## Engine
|
||
|
|
|
||
|
|
A line-by-line port. Classes keep their TypeScript names and methods, so a change to one engine is easy to repeat in
|
||
|
|
the other. The JSON of `MancalaGame` matches `JSON.stringify` of the TypeScript object, because clients rebuild it
|
||
|
|
with `MancalaGame.createFromMancalaGame`:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{ "id", "board": { "pits": [{ "index", "stoneCount" }], "playerPitCount", "initialStoneCountInPits" },
|
||
|
|
"player1Id", "player2Id", "turnPlayerId", "state", "gameRules": [{}, {}, {}, {}],
|
||
|
|
"history": [{ "boardSnapshot", "gameSteps": [{ "index", "type", "data" }], "playerId", "moveIndex" }],
|
||
|
|
"currentHistoryItem" }
|
||
|
|
```
|
||
|
|
|
||
|
|
Errors keep their TypeScript messages (`IndexOutOfRange …`, `StoneNotFound …`, `Player cannot move reason : …`);
|
||
|
|
they are `IllegalStateException`s.
|
||
|
|
|
||
|
|
## Threads
|
||
|
|
|
||
|
|
```
|
||
|
|
WebSocket event loop ──open/text/close──▶ game loop (1 thread) ──▶ GameManager, LiveGames, MatchMaker, RtmtServer
|
||
|
|
│ ▲
|
||
|
|
token check (worker) ◀───────────┘ └── timers: clock check 1 s, ping 2 s, auth timeout, preview push
|
||
|
|
HTTP worker ──call(read live state)──▶ game loop, waits for the result; reads saved games itself
|
||
|
|
game loop ──snapshot──▶ store thread ──▶ Valkey (ordered)
|
||
|
|
game loop ──record──▶ recorder thread ──▶ Postgres (retry every 30 s), then cache invalidation
|
||
|
|
```
|
||
|
|
|
||
|
|
`Scheduler` is the part of the game loop the logic uses: `execute`, `schedule(delay)`, `scheduleAtFixedRate` and
|
||
|
|
`now()`. Tests use `ManualScheduler`, which runs tasks at once and timers when the test advances its clock, in
|
||
|
|
place of Jest's fake timers. Every task is wrapped so an exception is logged and never stops a repeating timer.
|
||
|
|
|
||
|
|
`GameRecorder` and the live game store take an `Executor`; tests pass a direct one, so saves finish before the test
|
||
|
|
looks, in place of `flushPromises`.
|
||
|
|
|
||
|
|
## WebSocket
|
||
|
|
|
||
|
|
`RtmtEndpoint` (`@WebSocket(path = "/")`) handles `@OnOpen`, `@OnTextMessage` and `@OnClose` as `@NonBlocking` and
|
||
|
|
only posts them to the game loop, keeping the order of a socket's events. It wraps the connection as an
|
||
|
|
`RtmtSocket` (`id`, `query`, `send`, `close(code, reason)`, `isOpen`). Sends are asynchronous and never block the
|
||
|
|
loop.
|
||
|
|
|
||
|
|
`RtmtServer` ports `rtmt_websocket.ts`: per socket an `alive` flag, the user id once authenticated and the auth
|
||
|
|
timeout; per user the set of sockets and the `AuthUser`. Message JSON is parsed on the loop. A token is checked on a
|
||
|
|
worker and the outcome handled on the loop, where a socket closed in the meantime is ignored.
|
||
|
|
|
||
|
|
## HTTP
|
||
|
|
|
||
|
|
Quarkus REST resources, blocking (run on worker threads). Reads of live games go through `GameLoop.call`, and the
|
||
|
|
client game is serialised on the loop because it refers to the live `MancalaGame`. `BearerAuth` checks the
|
||
|
|
`Authorization` header and answers 401. Any other exception becomes 503, as the Express error handler did. CORS and
|
||
|
|
the access log are Quarkus HTTP settings.
|
||
|
|
|
||
|
|
## Startup
|
||
|
|
|
||
|
|
`Backend` (`@Startup`) builds everything, then queues on the game loop, before any WebSocket event can be queued:
|
||
|
|
wait up to 5 s for Valkey, restore live games, start the clock check, the ping and the recorder's retries.
|
||
|
|
Migrations run on their own thread and are retried every 30 s until Postgres is reachable. A restore that takes long
|
||
|
|
(Postgres down: each check waits for the pool's 5 s timeout) delays socket events but loses none.
|
||
|
|
|
||
|
|
## Configuration
|
||
|
|
|
||
|
|
| Variable | Default | Quarkus property |
|
||
|
|
|---|---|---|
|
||
|
|
| `PORT` | `5005` (`6000` in the image) | `quarkus.http.port` |
|
||
|
|
| `KEYCLOAK_ISSUER`, `KEYCLOAK_JWKS_URL`, `KEYCLOAK_CLIENT_IDS`, `LEGACY_USERKEY_AUTH` | as before | `mancala.auth.*` |
|
||
|
|
| `GAME_CLOCK_MS` | `300000` | `mancala.game-clock-ms` |
|
||
|
|
| `VALKEY_URL` | `redis://localhost:6379` | `quarkus.redis.hosts` |
|
||
|
|
| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | `quarkus.datasource.jdbc.url` |
|
||
|
|
| `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala` | `quarkus.datasource.username`, `.password` |
|
||
|
|
|
||
|
|
Tests use Quarkus Dev Services with the compose images (`postgres:17.6`, `valkey/valkey:9.1.2`); the unit tests need
|
||
|
|
no Docker.
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
- Unit (no Docker): engine and parity fixture, game manager, live games, clock scheduler, token verifier (local key
|
||
|
|
pair and JWKS), `RtmtServer` with fake sockets.
|
||
|
|
- `@QuarkusTest` (Docker): Postgres repository and migrations, Valkey store and cache (including an unreachable
|
||
|
|
Valkey), HTTP API, and the WebSocket endpoint with a real client and a test token verifier.
|
||
|
|
- Manual, in Chrome with `playwright-core` against the local stack (R8.2): two players log in as guests and play a
|
||
|
|
game to the end while a third user spectates and the home page shows the live preview; a player reloads mid-game;
|
||
|
|
the backend is restarted mid-game and the game goes on with the clocks intact; the finished game appears in recent
|
||
|
|
games and replays; a short `GAME_CLOCK_MS` ends a game on time.
|