From ec833b4ec59e8c57b6f3b4642f0fa2244d298077 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 11:00:44 +0300 Subject: [PATCH] docs(backend): spec the move to Java and Quarkus Requirements, design and tasks for rewriting the backend in Java 25 with Quarkus while keeping the protocol, the API and the stored data. --- docs/specs/quarkus-backend/design.md | 137 +++++++++++++++++++++ docs/specs/quarkus-backend/requirements.md | 88 +++++++++++++ docs/specs/quarkus-backend/tasks.md | 20 +++ 3 files changed, 245 insertions(+) create mode 100644 docs/specs/quarkus-backend/design.md create mode 100644 docs/specs/quarkus-backend/requirements.md create mode 100644 docs/specs/quarkus-backend/tasks.md diff --git a/docs/specs/quarkus-backend/design.md b/docs/specs/quarkus-backend/design.md new file mode 100644 index 0000000..bd84774 --- /dev/null +++ b/docs/specs/quarkus-backend/design.md @@ -0,0 +1,137 @@ +# 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. diff --git a/docs/specs/quarkus-backend/requirements.md b/docs/specs/quarkus-backend/requirements.md new file mode 100644 index 0000000..a163408 --- /dev/null +++ b/docs/specs/quarkus-backend/requirements.md @@ -0,0 +1,88 @@ +# Quarkus backend: requirements + +Status: implemented on branch `feature/quarkus-backend`. +Scope: `apps/backend`, its Docker image, `apps/compose.yaml` and the docs. + +## Goals + +The backend is rewritten in Java with Quarkus on the latest Java LTS (Java 25). Players, the web app and the mobile +app notice nothing: the WebSocket protocol, the HTTP API and the stored data stay as they are, and the new backend +can replace the Node backend in a running deployment without ending live games. + +## Terms + +- **Node backend**: the Express + `ws` backend in `apps/backend` before this change. +- **Java backend**: its replacement. +- **Wire format**: the JSON sent over the WebSocket and the HTTP API, as the clients in `packages/core`, + `apps/frontend` and `apps/mobile` read it. + +## Requirements + +### R1. Platform +- R1.1 The backend is a Quarkus application (the latest stable 3.x release) compiled for and run on Java 25. +- R1.2 It is built with Maven; dependencies have exact versions (the Quarkus platform BOM pins the extensions). +- R1.3 It runs in JVM mode. A native image is out of scope. +- R1.4 It no longer depends on `packages/core` or `packages/mancala.js`; the game rules it needs are part of the + Java backend. + +### R2. Game rules +- R2.1 The Java backend plays games by exactly the rules of `packages/mancala.js`: the same moves are allowed, the + same errors are raised, and every move produces the same board, the same history entry (board snapshot and game + steps with their data) and the same next player. +- R2.2 A test replays games recorded with the TypeScript engine and compares every result, including the JSON a + client receives, so the two engines cannot drift apart unnoticed. The recording can be regenerated with a script. + +### R3. WebSocket protocol +- R3.1 Clients connect to the server's root path, as now (`ws://localhost:5005`, `wss://api.playmancala.app`). +- R3.2 Every channel in `packages/core/src/rtmt/channel_names.ts` behaves as in the Node backend: the same messages + are sent to the same users in the same order, with the same JSON. This covers authentication (`auth`, `on_auth`, + `on_auth_error`, close code 4001, the 5 second timeout, token refresh), matchmaking, moves, leaving, spectating, + connection changes, live game previews and the ping/pong keep-alive. +- R3.3 Legacy clients that connect with `?userKey=` work while `LEGACY_USERKEY_AUTH` is on. +- R3.4 A user can be connected from several sockets; the user is offline when the last one closes. + +### R4. HTTP API +- R4.1 `GET /`, `GET /register/` (only with legacy auth), `GET /game/:id`, `GET /games/popular`, + `GET /games/me/active` and `GET /games/me` answer with the same status codes and JSON as in the Node backend. +- R4.2 CORS allows every origin, as now. +- R4.3 Requests are written to `access.log` in the common log format, as now. + +### R5. Data +- R5.1 The Java backend uses the existing Postgres schema and the `schema_migrations` table: on a database the Node + backend migrated, it applies nothing; on an empty one it creates the same tables. +- R5.2 It restores live games that the Node backend saved in Valkey (snapshot versions 1 and 2), and writes snapshots + the Node backend can restore, so a rollback also keeps live games. +- R5.3 It saves finished games that the Node backend left pending in Valkey, and pending records it leaves can be + saved by the Node backend. +- R5.4 Saved games and cached lists have the same JSON as before; a game saved by either backend reads the same. + +### R6. Behaviour when a dependency is down +Unchanged from `game-history` and `game-clock`: +- R6.1 Games are played while Postgres or Valkey is down. Finished games are kept and saved once Postgres is back; + lists are served without the missing data. +- R6.2 The backend starts while Postgres is down (migrations are retried) and while Valkey is down (live games are + then not restored). +- R6.3 Live games survive a restart; the time the backend is down is not charged to the clock. + +### R7. Configuration and deployment +- R7.1 The settings stay environment variables with the same names and defaults (`PORT`, `KEYCLOAK_*`, + `LEGACY_USERKEY_AUTH`, `VALKEY_URL`, `GAME_CLOCK_MS`), except the database: `DATABASE_URL` is replaced by + `DATABASE_JDBC_URL`, `DATABASE_USERNAME` and `DATABASE_PASSWORD`. `apps/.env` needs no change. +- R7.2 The Docker image is still built from the repo root with `docker build -f apps/backend/Dockerfile .`, listens + on port 6000 in the container and runs under `apps/compose.yaml` as before. +- R7.3 `mvn quarkus:dev` runs the backend locally on port 5005 against the compose services, like `yarn dev` did. + +### R8. Tests and docs +- R8.1 The Node backend's tests are ported: game manager, live games, clock scheduler, token verifier, WebSocket + server, HTTP API, Postgres repository and Valkey store and cache. Tests that need Postgres or Valkey run them in + Docker, as now. +- R8.2 The web app is played against the Java backend in Chrome: login, a full game between two users, a spectator, + live previews, reconnecting, the game clock, restart with a live game, and the replay of the saved game. +- R8.3 `README.md`, `apps/backend/README.md`, `AGENTS.md` and `TODO.md` describe the Java backend. + +## Out of scope + +- Changes to the protocol, the API or the stored data, including fixing their known quirks (e.g. `on_game_crashed` + sending `{}`). +- Running more than one backend instance. +- Publishing the Java engine as a library, or replacing the TypeScript engine in the clients. diff --git a/docs/specs/quarkus-backend/tasks.md b/docs/specs/quarkus-backend/tasks.md new file mode 100644 index 0000000..fe996d8 --- /dev/null +++ b/docs/specs/quarkus-backend/tasks.md @@ -0,0 +1,20 @@ +# Quarkus backend: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [ ] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port + with unit tests; `scripts/record-engine-games.js` and the parity test (R2). +- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, + `ManualScheduler`; `KeycloakTokenVerifier` with unit tests. +- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, + `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported + unit tests. +- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`, + `ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services. +- [ ] 6. **WebSocket and HTTP**: `RtmtServer` with unit tests, `RtmtEndpoint`, the HTTP resources, `Backend` + wiring and startup, configuration; `@QuarkusTest`s for the API and the WebSocket. +- [ ] 7. **Replace the Node backend**: move the project to `apps/backend`, Dockerfile, `.dockerignore`, compose, + remove the Node sources; build the image. +- [ ] 8. **End to end and docs**: the Chrome tests from `design.md` against the local stack; `README.md`, + `apps/backend/README.md`, `AGENTS.md`, `TODO.md`.