Files

181 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 4748, 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/`.