181 lines
12 KiB
Markdown
181 lines
12 KiB
Markdown
# WebSocket platform: how the design maps onto this repository
|
||
|
||
The design models the platform as eighteen Gradle modules under `modules/websocket`. This
|
||
repository's fail-closed registry (`src/config/architecture/modules.json`) owns the leaf list, so
|
||
those modules are packages inside the registered `:adapter:inbound:websocket` leaf — the same
|
||
resolution the JPA, GraphQL and web platforms reached.
|
||
|
||
That is only honest if the boundaries are machine-checked, so `WebSocketStableModule` declares each
|
||
module's package, its purity grade and its exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||
scans the source tree and fails when the two disagree in either direction. Promoting a package to
|
||
its own Gradle leaf later is a registry edit rather than an archaeology exercise.
|
||
|
||
## Where the module map deviates from the design, and why
|
||
|
||
| Design places it in | Here | Reason |
|
||
| --- | --- | --- |
|
||
| `WebSocketSubprotocolName` in `websocket-protocol` | `core` | The connection context must name the negotiated token, and the context is core — leaving the type in `protocol` made `core` depend on `protocol` while `protocol` already depended on `core`. The boundary test refused the cycle. The negotiation *policy* stays in `protocol`. |
|
||
| `StrictWebSocketJsonCodec` in `websocket-protocol` | `codec` (its own FRAMEWORK_BOUND module) | `protocol` is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end. |
|
||
| `budget -> core` | `core -> budget` | `budget` imports nothing from `core` — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards. |
|
||
| — | `stomp` module | The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it. |
|
||
|
||
## What the design's rules actually prevent
|
||
|
||
A few of the design's requirements read as style and are not. These are the ones worth keeping.
|
||
|
||
**`websocket-core-api` names no framework.** Stricter here than in the HTTP platform, because a
|
||
connection is a long-lived object owned by a container and reaching for the container's own session
|
||
type is tempting from everywhere. A CORE module has no `WebSocketSession`, so the same decision
|
||
serves both runtimes and is testable without a server. The detector's framework list was missing
|
||
`tools.jackson` (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper
|
||
unnoticed. Fixed in both this leaf and the web leaf.
|
||
|
||
**No Java class name on the wire.** A FQCN publishes the package layout, breaks every client on a
|
||
rename, and makes the receiver's type resolution an attack surface. `WebSocketMessageType` refuses
|
||
anything that looks like one; the manifest binds published names to records, and records cannot run
|
||
code while being populated.
|
||
|
||
**The payload is an encoded string, not a `Map`.** A map accepts any shape, defers validation to
|
||
whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire,
|
||
and brings unbounded nesting with it.
|
||
|
||
**Handlers cannot write.** `WebSocketHandlerContext` has no session and no write method. This is
|
||
what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a
|
||
handler that could write directly would bypass the queue, and every promise would hold only for the
|
||
handlers that cooperated.
|
||
|
||
## Two findings from building it
|
||
|
||
**Tomcat's graceful shutdown does not close WebSocket connections.** It waits for in-flight
|
||
*requests*, and an established WebSocket is not a request — so the shutdown completes with the
|
||
connections still open and they die when the socket is torn down. The client sees a **1006**,
|
||
indistinguishable from a network failure, which sends it into its most aggressive reconnect path at
|
||
exactly the moment the fleet is restarting. `PlatformWebSocketHandler` therefore implements
|
||
`SmartLifecycle` and closes its own connections with **1001 going away**, at a phase that runs
|
||
before the web server stops. Found by `TomcatWebSocketAbuseIT`, which failed on its first run.
|
||
|
||
**Both runtimes on one classpath is a silent outage.** Spring Boot deduces one application type
|
||
from what is present, and picks the servlet one. A deployment that declared reactive endpoints and
|
||
shipped both starts, reports healthy, and never answers. `WebSocketStackExclusivity` reads what the
|
||
classpath will actually produce and fails startup with a sentence explaining it.
|
||
|
||
## Lanes
|
||
|
||
```bash
|
||
cd src
|
||
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
|
||
./gradlew :adapter:inbound:websocket:websocketJettyTest # the second servlet container
|
||
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
|
||
```
|
||
|
||
The Nginx lane carries a deliberately broken configuration alongside the correct one
|
||
(`nginx-no-upgrade.conf`) so the lane proves its own assertions can fail. A contract that only ever
|
||
runs against a correct configuration cannot tell whether it is checking anything.
|
||
|
||
## The Stable behaviours that had no code
|
||
|
||
A late audit compared every named type in the Stable plan against the source tree rather than
|
||
against memory, and found six that nothing implemented. They are listed because the way they were
|
||
missed is more useful than the fact that they were: each is a *behaviour under failure*, and the
|
||
plan named it inside a task whose other half was already built — so the task read as done.
|
||
|
||
| Behaviour | Where it lives now |
|
||
| --- | --- |
|
||
| A queue observation an operator can read, separating a configured drop from a lossless overflow | `outbound/OutboundQueueSnapshot` + `OutboundQueue.snapshot()` |
|
||
| A timed-out correlation id that is remembered, so a late answer is not attached to a reused id | `handler/LateResponseTombstone` |
|
||
| Pooled-buffer retention on the reactive stack, bounded and counted | `webflux/WebSocketDataBufferPolicy` + `WebSocketDataBufferLifecycle` |
|
||
| What a proxy in front of this platform has to do | `release/WebSocketNginxProxyProfile` |
|
||
| What a rolling restart has to demonstrate, in order | `release/WebSocketRollingRestartScenario` |
|
||
| What the platform must show before promotion | `release/WebSocketStableReleaseGate` |
|
||
|
||
The three `release` types share a module identity (`RELEASE`, CORE) that names no other module.
|
||
That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the
|
||
evidence and the checklist would come from the same source. They are predicates over facts a release
|
||
engineer supplies, so a missing runtime stays a missing runtime.
|
||
|
||
`WebSocketStableReleaseGate` overlaps `advanced/release/AdvancedPromotionGate` in shape and differs
|
||
in one condition that matters. Advanced capabilities are off unless a deployment names them, so a
|
||
broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to
|
||
cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the
|
||
author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6
|
||
already refuses that as a source edge; nothing in it notices a type that arrived through packaging,
|
||
and the effect is identical — Stable that does not build without Advanced is a naming convention.
|
||
|
||
## Not yet implemented
|
||
|
||
**Tasks 47–48, the browser matrix.** The design asks for a protocol test client driven through
|
||
Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine,
|
||
which is a dependency and a network requirement this template does not otherwise carry, and a
|
||
browser lane that silently skips when the browsers are absent is worth less than no lane. The
|
||
properties it would cover that the Java client does not — that a browser cannot set headers on the
|
||
`WebSocket` constructor, and that it surfaces close codes to page script — are the reasons
|
||
`ONE_TIME_TICKET` and the standard close codes exist, and both are asserted at the unit level.
|
||
|
||
## Advanced capabilities
|
||
|
||
The Advanced expansion plan asks for fifteen Gradle modules under `modules/websocket-advanced/`.
|
||
They are packages under `advanced.**` in this leaf, for the same reason the Stable platform is —
|
||
`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and a fifteen-leaf
|
||
addition to satisfy a directory layout is a change to the registry, not to the architecture. The
|
||
separation the design wanted is enforced by `WebSocketStableModule` and by WS-ARCH-6, which fails
|
||
the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists;
|
||
it does nothing about a compile-time edge, and ArchUnit does.
|
||
|
||
Three of the fifteen needed their own module identity rather than sharing `advanced`:
|
||
|
||
- **`advanced-stomp`** (`advanced.stomp`) is `FRAMEWORK_BOUND`. STOMP here *is* Spring Messaging, and
|
||
folding it into `advanced` would have relaxed that module's purity for every capability in it.
|
||
- **`advanced-stomp-rabbit`** (`advanced.stomp.rabbit`) is separate again. The adapter parses a
|
||
protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery
|
||
depend on it. Different blast radius, so a deployment can refuse one and keep the other.
|
||
- Everything else resolves to `advanced` by longest-prefix, which is what lets `advanced.codec.cbor`
|
||
exist without its own edge set.
|
||
|
||
### What was adapted rather than copied
|
||
|
||
**Task 8's presence record shape.** The design specifies
|
||
`(actorFingerprint, activeConnectionCount, state, lastObservedAt)` with a four-state observation
|
||
model. Implemented with `WebSocketActorReference` in place of a bare fingerprint string — it carries
|
||
the fingerprint and refuses to be constructed from raw identity, which is the property the design
|
||
was buying with the field name. The four states are implemented as specified; `PresenceState.STALE`
|
||
and `OFFLINE` are distinct because collapsing them reports every user as disconnected during a Redis
|
||
partition, when the connections are fine and the index went dark.
|
||
|
||
**Task 14's Protobuf codec.** The descriptor compatibility gate and the profile are implemented and
|
||
tested. The encode/decode path is not: a Protobuf codec without generated message classes has
|
||
nothing to encode, and generating them requires a `.proto` source this template does not have and
|
||
should not invent. `DescriptorCompatibilityGate` is the part with the failure mode worth guarding —
|
||
a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy
|
||
both descriptor versions are live, so the receiver reads the wrong field without erroring.
|
||
|
||
**Task 15's CBOR codec.** Same shape, same reason: `CborCodecProfile` fixes canonical encoding and
|
||
the duplicate-key policy, which are the two settings that decide whether two systems reading the
|
||
same bytes agree. `WebSocketCborCodec` implements the encode/decode path on top of it, and
|
||
`SchemaParity` is what stops it publishing a different catalog than JSON.
|
||
|
||
`jackson-dataformat-cbor` and `protobuf-java` are `compileOnly` plus `testImplementation`, not
|
||
`implementation`. Both jars change an adopter's behaviour by their mere presence — Spring Boot
|
||
registers a `cborMapper` bean for the first and Spring registers a Protobuf message converter for
|
||
the second — so an adopting composition root would acquire both without enabling either capability.
|
||
The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see
|
||
`docs/web/repository-adaptation.md`. `WebSocketBinaryCodecBackend` turns an absent backend into a
|
||
sentence naming the missing coordinate, and `BinaryCodecBackendScopeTest` reads `build.gradle` and
|
||
fails if either returns to `implementation`.
|
||
|
||
**Task 12's relay configuration.** `RabbitBrokerRelayConfiguration` contributes only the broker, not
|
||
the destination prefixes. Two `WebSocketMessageBrokerConfigurer` beans each setting the application
|
||
prefix produce whichever ran last, silently — the same failure `StompBrokerExclusivity` exists to
|
||
catch between the two STOMP channels.
|
||
|
||
### Verification
|
||
|
||
```bash
|
||
cd src
|
||
./gradlew :adapter:inbound:websocket:test # 509 tests, Advanced included
|
||
./gradlew :adapter:inbound:websocket:websocketJettyTest
|
||
./gradlew :adapter:inbound:websocket:websocketNginxTest
|
||
```
|
||
|
||
See `docs/adr/ADR-WS-002-resume-and-cluster.md`, `docs/adr/ADR-WS-003-stomp-and-broker-relay.md`,
|
||
`docs/websocket/advanced-support-matrix.md` and `docs/websocket/runbooks/`.
|